Working document for a refactor that has landed, kept as the record of how. The JsObject query DSL
is gone, and so is the _deleted column it used to filter on — see
What was left for the three closing passes and the traps they hid.
Daikoku only supports PostgreSQL, but the shape of its data access layer is still inherited from
MongoDB: queries are written as JsObject documents with $in / $or / $regex / $gte / $ne
operators, translated to JSONB SQL at runtime by
Helper.convertQuery.
That translator is what we are removing. Its concrete problems: SQL placeholder numbering done by
hand and threaded through mutable vars; values interpolated straight into the SQL (notably
updateManyByQuery, which substitutes $N with hand-escaped values); blind ::bigint casts on
$gt/$gte/$lt/$lte; a hard two-level nesting limit; and silent failures — an unknown operator
becomes 1 = 1 (matches everything), an empty $in becomes 'DEFAULT VALUE TO AVOID EMPTY LIST',
and $options (regex flags) is ignored.
Done when: convertQuery is gone, no Json.obj(...) query with $ operators is left in the
code, and data access goes through named, typed methods backed by parameterised SQL.
- Storage is untouched. We keep one
content JSONBcolumn per entity — no data migration. Only the query surface changes. - The facade stays.
env.dataStore.xxxRepo,forTenant,DbConn/withTransactionall keep working exactly as they do now. Only the inside changes. - Migration is incremental, repo by repo. The
JsObjectAPI and the typed SQL API coexist during the transition;Helper.scalais deleted last.
| Phase | Scope | Status |
|---|---|---|
| 0 | Generic helpers of Repo |
Done — commit 39ec6b5f8 |
| 1 | Small repos: user session, password reset, account creation, evolution, reports info, email verification | Done — see git log |
| 2 | Mid-size tenant-scoped repos: tenantRepo, userRepo, teamRepo, notificationRepo, consumptionRepo, messageRepo, cmsRepo, assetRepo, subscriptionDemandRepo |
Done |
| 3 | Big ones: apiRepo, apiSubscriptionRepo, usagePlanRepo |
Done |
| 4 | Repos the plan had not listed: operationRepo, stepValidatorRepo, keyringRepo, apiDocumentationPageRepo, auditTrailRepo, translationRepo, apiIssueRepo, jobRepo, apiSubscriptionTransferRepo, apiPostRepo, emailVerificationRepo |
Done |
| Final A | Delete Helper.scala and the JsObject methods of Repo |
Done — rowToJson moved to pgimplicits, the file is gone |
| Final B | Slim down / dedupe the Repo layer |
Done — find* renaming, plus the index pass below |
| Final C | GIN on teams.users + rewrite of the membership lookups |
Done |
| Final D | Drop the dead _id / _tenant indexes |
Done — evolution_1900_b |
| Final E | Physical deletion, and dropping the _deleted column |
Done — evolution_1900_c to _e, see C |
Everything below already exists in the code — copy it rather than inventing something new.
One method per business intent, on the repo trait
(storage/api.scala), body executed through the
primitives already on Repo: query / queryOne / queryPaginated / execute / queryExists.
def findBySessionId(sessionId: String)(implicit
dbConn: DbConn,
ec: ExecutionContext
): Future[Option[UserSession]] =
queryOne(
s"SELECT content FROM $tableName WHERE content->>'sessionId' = $$1 LIMIT 1",
Seq(sessionId)
)Rules: values always go through params ($1, $2, …), never into the string. $in becomes
= ANY($n::text[]) with a Seq(xs.map(_.value).toArray) parameter. Optional/conditional filters are
handled locally inside their own method (Scala concat + a params buffer), case by case — no global
DSL, no combinator library. That dynamic assembly is exactly what made convertQuery unmaintainable.
The _deleted column is gone (see C). Nothing filters on it, so no
method carries a suffix in either direction: findById is the only spelling, and the
findByIdIncludingDeleted / findByIdsIncludingDeleted / findAllIncludingDeleted escape hatches
were removed once they became character-for-character identical to their twins.
This retired a trap worth remembering, because the same shape can reappear with any optional JSON
key: four entities never wrote _deleted at all — Message, Operation, Translation, Asset —
so content->>'_deleted' was NULL for them and a = 'false' predicate matched nothing. Adding
it to messageScope emptied every chat query and broke six tests; the same mistake on Translation
and Asset was silent. Before filtering on a JSON key, check the Format actually writes it.
find(JsObject) on a tenant-aware repo injects the tenant filter automatically. query(sql) does
not — the caller owns the whole SQL. Two situations:
- A method on
Repo(generic helper): build the WHERE clause withscopedWhere, which appendscontent->>'_tenant' = $nwhentenantScopeis set. - A method on a
TenantCapableRepotrait: take the tenant as a parameter and write the predicate by hand, asEmailVerificationRepo.deleteByTeamdoes.
Forgetting this leaks data across tenants, and no test will necessarily catch it.
tenantScope: Option[String]— hook, overridden byPostgresTenantAwareRepo.scopedWhere(predicates, params)— the only remaining place that assembles SQL; it only ever concatenates hard-coded predicates.queryExists(sql, params)— implemented inCommonRepo.
The generic helpers (findById, findByIds, findAll, deleteById, deleteAll, exists, the
whole findByIdOrHrId* family) are already parameterised SQL. Their signatures did not change, so roughly 800 call sites were migrated
without being touched. Id lookups now hit the _id PRIMARY KEY instead of content->>'_id'.
One parity quirk was kept on purpose: deleteById reports true even when it removes nothing.
The pass is done: createIndexes now covers the predicates the typed methods actually use. Two
tables had no index at all and got one — tenants (its domain is resolved on every HTTP
request in Hostname mode) and consumptions (the largest table of a busy instance). users.email
covers every login, and the action.user / demand / subscription / keyring paths of
notifications join their siblings, which were already indexed.
A, B and C have all landed; this section records what each one did.
Membership lookups used to scan. TeamRepo.isMemberSql was written as
EXISTS (SELECT 1 FROM jsonb_array_elements(content->'users') AS u WHERE u->>'userId' = $n)which no index can serve — the function unnests the array, so every row of the tenant's teams
had to be opened. The tenant predicate in front restricted it, but myTeams sits behind nearly
every page (catalogue, notifications, subscriptions), so it is the hottest path of the app on a
tenant with many teams.
The fix was one method plus one index, and both were needed — the index alone would never have been used:
isMemberSqlis now a containment test a GIN index can serve:content->'users' @> jsonb_build_array(jsonb_build_object('userId', $n::text)). The explicit::textresolves the parameter type, sincejsonb_build_objecttakes"any". Containment matches objects partially, so the{"userId": …}probe finds the full{userId, teamPermission}entry, at any rank in the array;CREATE INDEX IF NOT EXISTS idx_team_users ON teams USING GIN ((content->'users'));
ApiController.search spelled the same EXISTS inline and now calls the repo helper, which became
public for that reason. The four other callers (findPersonalTeam, isTenantAdmin,
findPersonalTeamsForAllTenants, findByUser) go through isMemberSql and followed for free.
MessageRepo.containsUser used @> already and needed nothing.
Measured with EXPLAIN (ANALYZE, BUFFERS) on findByUser, on a throwaway schema holding 200k teams
across 20 tenants (the dev database has 8 teams, which measures nothing):
| Execution | Heap blocks | |
|---|---|---|
EXISTS(jsonb_array_elements) |
85.2 ms | 9686 |
@>, no index |
19.2 ms | 9686 |
@> + GIN |
1.6 ms | 12 |
The predicate alone already wins 4× by not unnesting per row, but it stays in the Filter and still
opens every team of the tenant. With the index it moves to a Recheck Cond over a Bitmap Index
Scan.
Note idx_team_users is unrelated to the neighbouring uniq_team_personal_user, which looks
similar but is partial (WHERE … 'Personal') and positional (content->'users'->0) because it
enforces "one personal team per user and per tenant". The GIN index has neither restriction and
indexes every member of every team.
Two jsonb_array_elements on teams were deliberately left alone: the ones in evolutions.scala,
which replay a past behaviour, and DeletionService.removeUserFromTeams, which unnests the array to
rewrite it, not to filter — its own WHERE was already a containment test.
Every entity carries its id twice: the _id column (which holds the PRIMARY KEY) and the JSON key
content->>'_id'. Since phase 0 the generic helpers query the column, so the 8
((content->>'_id')) indexes were only maintained — on every insert and every save — and never
read. Same for ((content->>'_tenant')) on users: UserRepo is a plain Repo, not a
TenantCapableRepo, so no user query filters on _tenant.
The check this section used to give was wrong, and it is the same blind spot as the phase-3 one:
it grepped a shape instead of the thing itself. content->>'_id' only matches the unspaced
spelling, and the raw SQL of CommonServices is written spaced. Spell it as a regex and 52
occurrences show up instead of the 8 index declarations:
grep -rEn "content *->> *'_id'" daikoku/app daikoku/testFour of them were live reads on a physical table, i.e. two of the eight indexes were not dead:
| Index | Read by |
|---|---|
idx_api_id |
CommonServices — the catalogue's IN on api groups, and the notification/api join |
idx_team_id |
CommonServices — JOIN teams t ON t.content ->> '_id' = va.content ->> 'team' |
Those four now read the _id column, which is strictly better: same data (verified in the database,
zero divergence between _id and content->>'_id' on every table), and it hits the PRIMARY KEY
instead of an expression index. The rewrite of the correlated IN was checked for scope equivalence
— two nested unaliased FROM apis, where the unqualified _id must still bind to the outer one —
by running both spellings side by side.
The remaining ~17 occurrences are projections off the my_teams CTE
(select t.content ->> '_id' from my_teams t). They read no index, so they were left alone; the CTE
does expose _id (SELECT teams.*), so switching them would save a JSON extraction each on the
catalogue queries. Worth doing, not needed here. The evolutions.scala joins were left alone too:
they replay a past behaviour and run once.
The drop itself is a migration, not a CREATE INDEX IF NOT EXISTS: evolution_1900_b, with the
9 declarations spelled out in a comment so it can be replayed by hand — the evolution mechanism has
no down-script. The CREATE INDEX lines are gone from createIndexes, so a fresh database never
builds them in the first place.
Deletion used to flag rows _deleted = true and run the Otoroshi/Stripe cleanup synchronously in
the request, purging physically later from the queue — a timeout risk on a large closure, and a
window of half-deleted state. The column is now gone, along with notDeletedSql, the whole
deleteLogically* family, the _deleted JSON key, the idx_*_deleted indexes and the
*IncludingDeleted method pairs.
The write-side pattern. Every deletion path now:
- deletes the whole DB closure physically and atomically in one
withTransaction; - defers every external call to the deletion queue via self-contained operations — the payload
carries what the call needs (an orphaned keyring's
{clientId, otoroshiSettings}), because the row is gone.(Keyring, Delete)deletes the apikey,(Keyring, Sync)recomputes it (row still there), and the api's own DB cleanup moved into the transaction, so(Api, Delete)disappeared.
The async move is what forces the transaction: you cannot hold a DB transaction open across N Otoroshi round-trips, so "atomic + no synchronous HTTP" and "no half-deleted closure" are the same requirement. Idempotency was already there (the external calls tolerate 404 on retry); a frozen payload is more deterministic on retry than re-reading a mutated tombstone.
The order the passes had to run in. Each one was landed green on its own, and the sequence is not interchangeable:
- Write side, one slice at a time — subscription, keyring and the api cascade, then team, user,
plan, then tenant, then the generic admin CRUD. The
?logically/?notDeletedadmin-api parameters became no-ops here. Until all of them were physical the reads could not be touched: an earlier attempt at the read side was reverted precisely because tenant still soft-deleted. evolution_1900_cpurges the legacy_deleted = truerows. It must run before the read filter goes, otherwise those tombstones resurface as live data.- Read side —
notDeletedSqland the ~15 methods carrying it, then the 47 raw-SQL sites in controllers, jobs, services andCommonServices. Behaviour-preserving, since step 2 left no row the filter could exclude. - The dead code — the logical-delete write family (already unreachable) and the vestigial admin
parameters. Then the
*IncludingDeletedcollapse: 249 call sites renamed onto their twins, which the compiler covers, since the bodies had become identical. - Frontend before backend. Two GraphQL queries selected the flag (
apiByIdsWithPlansasked fordeleted,plansByApifor_deleted). Sangria rejects a whole query on an unknown field, so the client had to stop asking before the schema dropped the fields — removing a field from a query works against the old server, the reverse does not. - Stop writing — the
deletedfield on the 19 case classes, its 40 lines injson.scala, the 9 GraphQL fields, thesaveRawbranch that populated the column. evolution_1900_edrops the column on its 24 tables.
Two traps that fail silently. Neither surfaces as an error, which is what makes them worth recording:
- The partial unique index.
uniq_team_personal_userwasWHERE _deleted = false AND type = 'Personal'. The moment the entities stop writing the key,saveRawleaves the column NULL, andNULL = falseis not true — so the index quietly stops covering new rows and the "one personal team per user" uniqueness is lost with no error anywhere.evolution_1900_drebuilds it without the predicate, in the same commit that stops the writes. - The
idx_*_deletedindexes surviveDROP COLUMN. They are expression indexes oncontent->>'_deleted', the JSON key, not on the column, so Postgres has no dependency to cascade.evolution_1900_edrops them explicitly.
Two public contracts changed, both worth a release note: the GraphQL schema lost deleted on
nine types, and the admin-api payloads lost _deleted (its twelve properties are gone from
admin-api-openapi.json).
- Testing note: the external cleanup is asynchronous, so any test asserting Otoroshi/Stripe state
right after a deletion must first wait for the queue, via the shared
awaitDeletionQueueDrained(tenant)helper insuites.scala. - Testing note: watch for assertions of the form
maybeThing.forall(_.deleted) mustBe true.forallonNoneistrue, so they passed precisely when the row was gone — two of them were asserting nothing at all.
grep -rn "dataStore\.<repo>" app/ test/and list everyJson.obj(...)handed to that repo.- Add the typed methods covering each pattern (tenant predicate spelled out for tenant-aware repos).
- Rewrite the call sites — controllers, services, jobs, and GraphQL
(
domain/SchemaDefinition.scala,domain/CommonServices.scala). - Migrate the matching backend tests (
daikoku/test/, notablysuites.scala, the shared harness, andcontrollers/TransactionSpec.scala, sensitive toDbConnthreading). - Run the suite. One digestible PR per phase.
- Logical deletion. Settled, and it went the simpler way: physical deletion landed after the
typed methods, so the
findNotDeleted/deleteLogically/notDeletedfamily was removed wholesale rather than migrated. See C. AuditTrailRepohasOf = JsObject(schemaless). It stays JSONB, just with dedicated typed methods.- Export / import. Settled:
streamAllRaw/streamAllRawFormattednow go throughReactivePg.queryStreamSource, the server-side cursor that already backedqueryRawMappedStream. They used to build aSourceout of a fully materialisedSeq— the type said stream, the behaviour was a plainSELECT *, andexportAsStreampeaked at the size of the largest table (consumptionsoraudit_eventson a busy instance). Memory is now bounded byfetchSize. findWithProjectionhas few callers: either provide an SQL replacement returning the wanted columns, or move those callers tofind+map.- Indexes. The ~60 JSONB expression indexes (
createIndexes,PostgresDataStore.scala) stay valid — same storage. What the migration reveals they don't cover is collected below.
convertQuery is gone. What went with it, in one commit:
- 15
JsObjectmethods off theRepotrait —find,findOne,findRaw,findOneRaw,findNotDeleted,findOneNotDeleted,findOneNotDeletedRaw,findWithProjection,findOneWithProjection,findWithPagination,delete,deleteLogically,updateMany,updateManyByQuery,count(JsObject),exists(JsObject)— and their 19 implementations inPostgresDataStore, which shrinks by ~680 lines. Helper.scalakeeps onlyrowToJson: 323 lines down to 35.
Two things deliberately stayed JsObject:
saveRaw(id, payload), the formersave(query, value)minus its query argument. The JSON here is the stored representation, not a query, and four evolutions need it to write entities in the shape of their period — the current formats would refuse to read them back.AuditTrailRepo, whoseOfisJsObject. Audit events are schemaless by design; only its query surface is typed now.
streamAllRaw / streamAllRawFormatted lose their query argument and keep loading the whole table
before emitting — they never streamed, and the export and evolutions are their only callers.
The repos the plan had forgotten turned out to be the most repetitive: 21 of operationRepo's 23
queries were the same "pending operations" copy-paste across the test suites, and most of the rest
were _id $in [...]. Two generic helpers absorbed them: deleteByIds, the pendant of findByIds,
and findByIdsPaginated.
Three more queries that matched nothing:
- The queue's concurrency guard.
QueueJob.deleteFirstOperationaskedexists({"Status": "InProgress"})— capital S, while anOperationwritesstatus.content->>'Status'is always NULL, soalreadyRunningwas always false and the queue could pick up a second operation while one was still running. - Team and tenant translations.
TeamController.teamHomeandTenantController.getTenantlook translations up byelement.id, butTranslationis(tenant, language, key, value)— it has noelementfield any more. Both endpoints have always answered an emptytranslationobject. Ported as-is: restoring it means deciding how a translation names its entity, which the model no longer expresses. fetchPagesonApiDocumentationhad no caller at all. Removed.
The last two findWithProjection went with them. Note the projection returned every column as
text, so the documentation titles endpoint rendered lastModificationAt as a string; the typed
replacement keeps that, since the front reads only _id, title and level.
apiRepo closes the phase: 66 queries, and the widest domain surface of the migration because an api
is versioned. _humanReadableId is shared by every version, currentVersion tells them apart,
parent is null on the root and isDefault marks the served one — so most methods name a point in
that tree (findRootVersion, findOtherVersions, existsVersion, clearDefaultVersionExcept,
reparentVersions, renameHumanReadableId) rather than a WHERE clause.
Two things it exposed:
- A
++that reads like a bug but is not.AdminApiController.validatebuiltJson.obj("_id" -> {"$ne": id}, "name" -> name) ++ parent.map(p => Json.obj("_id" -> p)).++on aJsObjectoverwrites the duplicate key, so with a parent the query silently becomes "the parent api bearing this name" — which is exactly what the caller wants: creating a new version must find its parent and accept it. Rewriting it on the name alone brokeAdminApiControllerSpec("create a new version of API (with same name)" got a 400), becauseLIMIT 1may return any other version.findAnotherWithNametherefore takes the parent and reproduces both branches. - A job that verified nothing.
OtoroshiEntitiesVerifierJobfilters the apis stream with a query built from its entry point, which names subscription fields (api,plan, or the subscription's_id). AnApicarries none of them, so every entry point butSyncAllSubscriptionreduced the stream to nothing. Ported as-is with the behaviour spelled out in a comment: fixing it means deciding which api a plan or a subscription should resolve to, which is a product call.
Grepping <repo> followed by a JsObject call misses every query built into a variable or a helper
(referencingPlans(query) in TenantService, uniquenessQuery in ApiCrudService,
val apiRepo = … then apiRepo.findNotDeleted(...)). Three real call sites hid there. The reliable
scan walks every JsObject-API call and attributes it to a repo by reading backwards — that is what
the final check below does.
apiSubscriptionRepo is the widest surface of the phase — 54 queries — but a narrow set of
dimensions: api, team, plan, keyring, apiKey.clientId. The keyring ones carry the
aggregation model (several subscriptions share one Otoroshi apikey), which is why they get named
methods rather than a generic filter: findKeyringSiblings, countByKeyring and
updateApiKeyOfKeyring each answer a question the domain asks, not a shape of WHERE clause.
Migrating it needed one new primitive on Repo: queryCount, the parameterised counterpart of
count(JsObject). Without it a count had to go through queryPaginated with LIMIT 0, which runs
two statements to throw one away.
A warning for the remaining apiRepo step: a regex rewrite of the .forTenant(x).findNotDeleted( Json.obj("team" -> …)) shape silently matched apiRepo as well as apiSubscriptionRepo. The
compiler caught it, but only because ApiRepo had no findByTeam yet — once it does, the same
mistake compiles. Rewrite call sites repo by repo, never by shape alone.
usagePlanRepo turned out to be the easy one of the three: eighteen of its twenty-one queries were
_id $in [...], which the generic findByIdsNotDeleted of phase 0 already covers. A plan does not
name its api — the relation is carried the other way round, by Api.possibleUsagePlans — so
findByApi is just that helper applied to the api's plan ids. Only findByCustomName needed a
method of its own.
It also exposed a test that could not fail. ApiControllerTeamAdminSpec, under the comment "test if
plans are deleted", asserted findNotDeleted(Json.obj("api" -> api.id)).isEmpty. UsagePlan has no
api field at all, so content->>'api' is NULL, the predicate is never true, and the query returned
nothing whatever the state of the database. It now looks the plans up by id — which means it can now
actually fail.
Closing the phase also removed two more JsObject methods from Repo: findMaxByQuery (see the
messageRepo note) and the last findWithProjection outside apiRepo — an Asset is
(id, tenant, slug), so loading them whole costs exactly what the projection did. The three
remaining findWithProjection callers are all on apiRepo, i.e. phase 3.
messageRepo exposed a third silently-broken query, of the same family as the phase-1 one.
ReadMessages marked a chat as read with {"readBy": {"$ne": userId}}; $ne renders as
content->>'readBy' <> $n, and readBy is an array, so content->>'readBy' yields the whole
array as text (["u1","u2"]) — never equal to a single id. The guard was therefore always true and
the $push appended the same id again on every read, growing readBy without bound. It is now
NOT (content->'readBy' @> to_jsonb($n::text)).
Migrating it also retired findMaxByQuery, whose only two callers were the chat-date lookups: they
now select the newest matching message and read its closed field, which needs no extra primitive.
consumptionRepo is the first repo whose trait signature had to change: getLastConsumptionsForTenant
/ getLastConsumptionsforAllTenant / getLastConsumption took a JsObject filter, so they went with
the DSL. Their implementation — the only hand-written lastConsumptions in PostgresDataStore —
grouped on clientId to get MAX(content->>'from'), a textual max over millis, then issued one
extra query per client id to fetch the matching row, with neither the tenant nor the original filters
reapplied. A single DISTINCT ON (content->>'clientId') … ORDER BY … (content->>'from')::bigint DESC
replaces all of it: one query instead of 1+N, a numeric comparison, and the filters applied once.
Note the two spellings the consumption queries used for a time window — from >= start AND to <= end
and from BETWEEN start,end AND to BETWEEN start,end — are equivalent, since from <= to always
holds. One windowSql covers both.
notificationRepo retires the last three updateManyByQuery call sites of the phase — the method
that substitutes $N with hand-escaped values straight into the SQL. Their $set becomes the
jsonb_set(content, '{key}', …) that convertQuery generated anyway, this time with the value bound
as a parameter. The one streamAllRaw(query) of the notification evolutions goes through
dataStore.queryRaw + Source, which is literally what streamAllRaw does internally: it loads
everything, then emits. Nothing was streaming, so nothing regressed.
teamRepo is the first tenant-scoped repo of the phase, so the forTenant trap applies: its typed
methods live on the TeamRepo trait, take the tenant as a parameter and spell the _tenant
predicate out (bound to $1 by convention). The {"users.userId": …} membership query becomes
EXISTS (SELECT 1 FROM jsonb_array_elements(content->'users') AS u WHERE u->>'userId' = $n), which
needs no jsonb literal and no cast. There was no GIN index on content->'users' at the time, so the
former @> containment was not indexed either — no regression. Both were revisited in
item A, which put the containment form back and added the
index.
userRepo exposed two queries that never matched, both of the shape flagged after phase 1 — a
faithful-looking port of a query that returned nothing:
NotificationController(team-access notification) andApiService(subscription demand) looked up team administrators with_id $in team.users.filter(Administrator).map(_.asJson).asJsonon aUserWithPermissionserialises the whole{userId, teamPermission}object, so the$incompared an id against objects and found nobody: those two mails were never sent to anyone.ApiController.getIssuefiltered on$idinstead of_id. An unknown$operator falls back to1 = 1inconvertQuery, so the query returned every user of the instance rather than the authors of the issue comments.
Both now go through findByIdsNotDeleted(team.admins()).
Migrating exposed a real bug, fixed in the same commit rather than ported as-is (deliberate call).
A UserSession has two independent identifiers: _id and sessionId, two unrelated random
tokens. Both session purges filtered on _id while passing sessionId values, so they deleted
nothing: the admin "remove all sessions" endpoint and the switch to maintenance/construction mode
never disconnected anybody. The maintenance one also dropped the Future of its delete, so the
tenant was saved before the purge ran. Both now go through deleteAllExceptSession, properly
sequenced.
Worth keeping in mind for the next phases: a query that looks like a faithful port may be one that never matched anything. Check the field names against the entity's JSON format.
These cost several hours during phase 1 — read before debugging a red suite.
mise run test:backsilently exits 0 without running anything when a Postgres is already listening on 5432. That is a false green. The equivalent that actually runs issbt testfromdaikoku/(that is literally what the process-composeTestsprocess does).- Concurrent JVMs corrupt the build. IntelliJ's nailgun compiler and the Bloop daemon compile
into the same
daikoku/target/scala-3.8.2/classesas a terminalsbt test. When they recompile mid-run the suite fails with misleading symptoms:error while loading <Trait>, …/X.tasty,NoClassDefFoundErroron domain classes,*** ABORTED ***suites, or one isolated failure that turns green when the suite is replayed alone. Checkps -Ao pid,etime,command | grep [j]avabefore suspecting the code. sbt scalafmtreformats ~23 unrelated files — the repo is not up to date formatting-wise. Restore them before committing, or clean it up in a separate commit.- The suite needs Otoroshi reachable at
otoroshi-api.oto.tools(public wildcard DNS → 127.0.0.1), so it fails wholesale when the machine is offline.
- Abstraction:
storage/api.scala - Translator to delete:
storage/drivers/postgres/Helper.scala - Implementation:
storage/drivers/postgres/PostgresDataStore.scala(PostgresRepo,PostgresTenantAwareRepo,CommonRepo) - Biggest consumers:
controllers/ApiController.scala,services/ApiService.scala,jobs/QueueJob.scala,controllers/AdminApiController.scala,services/DeletionService.scala,domain/SchemaDefinition.scala,domain/CommonServices.scala
# Every JsObject-API call, attributed to its repo by reading backwards.
python3 - <<'EOF'
import re, glob
files = glob.glob('daikoku/app/**/*.scala', recursive=True) + \
glob.glob('daikoku/test/**/*.scala', recursive=True)
meths = r'\.(find|findOne|findNotDeleted|findOneNotDeleted|findRaw|findOneRaw|' \
r'findWithPagination|findWithProjection|delete|deleteLogically|' \
r'updateMany|updateManyByQuery|streamAllRaw|streamAllRawFormatted|count|exists)\('
for p in sorted(files):
s = open(p).read(); lines = s.split('\n')
for m in re.finditer(meths, s):
depth = 0
for j in range(m.end() - 1, min(m.end() + 3000, len(s))):
if s[j] == '(': depth += 1
elif s[j] == ')':
depth -= 1
if depth == 0: break
if 'Json.obj' not in s[m.end():j]: continue
ln = s[:m.start()].count('\n')
if lines[ln].strip().startswith('//'): continue
repo = next((re.search(r'(\w+Repo)\b', lines[k]).group(1)
for k in range(ln, max(0, ln - 8), -1)
if re.search(r'(\w+Repo)\b', lines[k])), None)
print(p + ':' + str(ln + 1), repo)
EOFand Helper.scala gone.