@ultimat3/entity 1.2.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +694 -0
- package/README.md +467 -16
- package/package.json +6 -4
- package/src/batch-read.ts +134 -0
- package/src/batch.ts +125 -0
- package/src/bulk-write.ts +285 -0
- package/src/coalesce.ts +189 -0
- package/src/column.ts +91 -2
- package/src/columns-data.ts +205 -0
- package/src/columns.ts +228 -38
- package/src/count-by.ts +148 -0
- package/src/cross-tenant.ts +76 -0
- package/src/cursor.ts +17 -3
- package/src/database.ts +35 -2
- package/src/describe.ts +121 -39
- package/src/entity.ts +65 -20
- package/src/errors.ts +346 -5
- package/src/expr.ts +65 -15
- package/src/index.ts +72 -7
- package/src/invariants.ts +56 -14
- package/src/jit-preload.ts +216 -0
- package/src/n-plus-one.ts +122 -0
- package/src/pg-driver.ts +282 -35
- package/src/pg-row.ts +87 -16
- package/src/pg-sql.ts +156 -13
- package/src/plan.ts +130 -20
- package/src/preload.ts +184 -0
- package/src/query.ts +231 -27
- package/src/registry.ts +63 -5
- package/src/relations.ts +212 -0
- package/src/repo.ts +226 -12
- package/src/seed.ts +288 -19
- package/src/tenancy.ts +194 -12
- package/src/type-pins.ts +311 -0
- package/src/types.ts +114 -12
- package/src/view.ts +8 -2
package/README.md
CHANGED
|
@@ -22,10 +22,10 @@ export const posts = entity('posts', {
|
|
|
22
22
|
createdAt: timestamp().defaultNow(),
|
|
23
23
|
updatedAt: timestamp().defaultNow().onUpdateNow(),
|
|
24
24
|
},
|
|
25
|
-
invariants: [
|
|
26
|
-
invariant('post_title_present',
|
|
27
|
-
invariant('post_slug_unique_per_org',
|
|
28
|
-
invariant('post_like_count_non_negative',
|
|
25
|
+
invariants: (c) => [
|
|
26
|
+
invariant('post_title_present', c.title.trimmed().minLength(1)),
|
|
27
|
+
invariant('post_slug_unique_per_org', c.unique(['orgId', 'slug'])),
|
|
28
|
+
invariant('post_like_count_non_negative', c.likeCount.atLeast(0)),
|
|
29
29
|
],
|
|
30
30
|
indexes: [{ on: ['orgId', 'createdAt'], order: 'desc', where: (c) => c.status.eq('published') }],
|
|
31
31
|
});
|
|
@@ -59,16 +59,83 @@ still imports one package: `import { entity, t } from '@ultimat3/entity'`.
|
|
|
59
59
|
|
|
60
60
|
| Builder | Emits | Why it is the only way |
|
|
61
61
|
|---|---|---|
|
|
62
|
-
| `uuid()` | `uuid`; `.primaryKey()` defaults to v7 | time-ordered keys keep the pk index append-friendly |
|
|
62
|
+
| `uuid()`, `uuid<PostId>()` | `uuid`; `.primaryKey()` defaults to v7 | time-ordered keys keep the pk index append-friendly; the optional brand is declared once and survives to every signature |
|
|
63
63
|
| `timestamp()` | `timestamptz` | UTC storage is not a per-table decision; there is no naive variant |
|
|
64
|
-
| `money()` | `<name>_minor bigint` + `<name>_currency char(3)` | never a float, never one implied currency |
|
|
64
|
+
| `money()` | `<name>_minor bigint` + `<name>_currency char(3)` + `<name>_scale integer null` | never a float, never one implied currency. The row value is `@ultimat3/schema`'s `MoneyValue` — the same declaration `@ultimat3/money`'s `Money` is — so a decoded row goes straight to `add()`/`formatMoney()`. A writer may hand a `bigint`; a stored minor unit past ±2^53 is refused on read, never rounded. `scale` is the decimal exponent `minor` counts in when it is not the currency's own (`{ minor: 2, currency: 'USD', scale: 6 }` is $0.000002); NULL in the column means "the currency's own minor unit" and round-trips as an ABSENT key, never as `0` |
|
|
65
65
|
| `enumerated(v)` | `text` + CHECK | a variant is a one-line migration, not `ALTER TYPE` |
|
|
66
66
|
| `tz(zones)`, `locale(tags)` | `text` + CHECK, `Intl`-validated at declaration | an offset is not a time zone |
|
|
67
67
|
| `text({ max })`, `integer()`, `boolean()`, `url()` | `text`/`integer`/`boolean` + CHECK | format is enforced by the database too |
|
|
68
68
|
|
|
69
69
|
Chain: `.primaryKey()` · `.nullable()` · `.unique()` · `.default(v)` · `.defaultNow()` ·
|
|
70
|
-
`.onUpdateNow()` · `.references(() => other.id, { onDelete })` · `.tenant()
|
|
71
|
-
derived from the property key (`orgId` → `org_id`); a name is written once, or
|
|
70
|
+
`.onUpdateNow()` · `.references(() => other.id, { onDelete })` · `.tenant()` · `.column(name)`.
|
|
71
|
+
Physical names are derived from the property key (`orgId` → `org_id`); a name is written once, or
|
|
72
|
+
never — `.column()` is the exception, and it exists for tables this framework did not create.
|
|
73
|
+
|
|
74
|
+
## Wide columns
|
|
75
|
+
|
|
76
|
+
The vocabulary an EXISTING schema needs. The blessed set above is a decision the framework made
|
|
77
|
+
for a table it was going to create; these are the shapes a table already has, `As of 2026-08`.
|
|
78
|
+
|
|
79
|
+
| Builder | Emits | Row type, and why |
|
|
80
|
+
|---|---|---|
|
|
81
|
+
| `json(schema)` | `jsonb` | the schema is **required**: a `json()` returning `unknown` is the `any` hole this framework forbids, and a column is the worst place for one — the value arrives from the database as often as from a caller. The object is bound as an object, never as JSON text |
|
|
82
|
+
| `decimal({ precision, scale })` | `numeric(p, s)` | a `string`, the exact digits. Money is the one decimal with an opinion (integer minor units + a currency); this is every other one, and a value with more decimal places than the column stores is refused rather than rounded |
|
|
83
|
+
| `date()` | `date` | `@ultimat3/time`'s `PlainDate` — a calendar date, no time, no zone. `effective_on` is the date a rate applies, and as a `timestamptz` it is a different date on either side of midnight for half the planet |
|
|
84
|
+
| `bigint()` | `bigint` | a decimal `string`. A JS `bigint` is what `JSON.stringify` throws on and a `number` loses digits past 2^53 — which is exactly where a legacy `int8` key lives. Both driver spellings (a string from Bun's `sql`, a `bigint` from PGlite) arrive as one |
|
|
85
|
+
| `bytes()` | `bytea` | a plain `Uint8Array`, normalised: Bun's `sql` returns a `Buffer` and PGlite a `Uint8Array`, and the two do not serialise alike |
|
|
86
|
+
| `arrayOf(column)` | `<element>[]` | `readonly T[]`, each member parsed by the element column it was given. Money and nested arrays are refused — an element is one scalar column |
|
|
87
|
+
|
|
88
|
+
## Adopting an existing table
|
|
89
|
+
|
|
90
|
+
Three overrides, and together they are what makes a schema Ultimate did not generate declarable
|
|
91
|
+
at all. Nothing here changes what an entity without them emits.
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
import { date, entity, money, text, uuid } from '@ultimat3/entity';
|
|
95
|
+
|
|
96
|
+
export const accounts = entity('account', {
|
|
97
|
+
table: 'legacy_accounts',
|
|
98
|
+
columns: {
|
|
99
|
+
id: uuid().primaryKey().column('account_id'),
|
|
100
|
+
githubLogin: text({ max: 40 }).column('gh_login'),
|
|
101
|
+
balance: money({ columns: { minor: 'amount_cents', currency: 'currency', scale: null } }),
|
|
102
|
+
openedOn: date().column('opened_on'),
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
| Override | What follows it |
|
|
108
|
+
|---|---|
|
|
109
|
+
| `entity(name, { table })` | every statement, index name and foreign key. The entity NAME stays the framework's key — the registry, the cache tag (`entity:account`), `x entities describe` and every relation are keyed by it, so renaming a table never moves a cache tag or a policy |
|
|
110
|
+
| `.column(name)` | the DDL, the binding, the decoder, the predicate, the sort key, the cursor. Name it LAST in a chain — the link returns the general column, and only `uuid()` and `timestamp()` keep their own methods across it |
|
|
111
|
+
| `money({ columns })` | per part, merged over `<name>_minor` / `<name>_currency` / `<name>_scale`, so a table that renamed one does not restate the other two. `scale: null` says the table has no scale column: every amount is then at the currency's own minor unit, which is what an absent scale already means |
|
|
112
|
+
|
|
113
|
+
A physical name is checked where it is written — lower-case letters, digits and underscores, at
|
|
114
|
+
most the 63 bytes Postgres truncates at — because it is spliced into every statement as an
|
|
115
|
+
identifier.
|
|
116
|
+
|
|
117
|
+
**What is not adoptable yet**, `As of 2026-08`: a `numeric` money column with no currency column
|
|
118
|
+
beside it (`money()` is two columns by construction — declare it `decimal()` and keep the currency
|
|
119
|
+
in the app), a Postgres `enum` TYPE (`enumerated()` emits a CHECK, not a `CREATE TYPE`), a
|
|
120
|
+
composite type, and a live query over a renamed column — `@ultimat3/realtime` rebuilds a row from
|
|
121
|
+
the physical names alone and would deliver `ghLogin` where the repository says `githubLogin`.
|
|
122
|
+
|
|
123
|
+
## Branded ids
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
export const posts = entity('posts', {
|
|
127
|
+
columns: { id: uuid<PostId>().primaryKey(), authorId: uuid<UserId>().references(() => users.id) },
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const post = await db.posts.findById(postId); // PostId — a UserId here is a compile error
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
The brand is declared once, on the column, and carried by the whole chain: `RowOf`, `Insertable`,
|
|
134
|
+
`Repo.findById/update/delete` and `Table.update/delete`, whose id parameters are `IdOf<Row>` —
|
|
135
|
+
the type the entity's own `id` column declared. `IdOf` collapses to `string` for a row that
|
|
136
|
+
declared no brand and for a composite key, so an unbranded entity reads exactly as it always did.
|
|
137
|
+
Nothing is checked at runtime: a brand has no witness, `$parse` still validates the uuid, and
|
|
138
|
+
`type-pins.ts` is where the claim is enforced.
|
|
72
139
|
|
|
73
140
|
## Invariants run twice
|
|
74
141
|
|
|
@@ -101,6 +168,206 @@ these filters, this sort order. A tampered cursor, or one taken from another lis
|
|
|
101
168
|
`X_CURSOR_INVALID` rather than a silent page one. The page size is deliberately outside the scope:
|
|
102
169
|
asking for a bigger next page is the same query.
|
|
103
170
|
|
|
171
|
+
**A page is bounded whether or not the caller bounded it.** `DEFAULT_PAGE_SIZE` (50) covers the read
|
|
172
|
+
nobody sized; `MAX_PAGE_SIZE` (10,000) covers the one they did — `limit(input.pageSize)` on a number
|
|
173
|
+
that arrived over the wire is the same production incident with an argument in front of it. A page
|
|
174
|
+
size that is not a whole number of rows in `1..MAX_PAGE_SIZE` is `X_INVARIANT_VIOLATED` on the chain
|
|
175
|
+
and again inside the plan both drivers build, so `findMany({ limit })` straight at the repository
|
|
176
|
+
cannot route around it. `inBatches(size)` is the call that means "every row" — one page per
|
|
177
|
+
statement, never a table in memory.
|
|
178
|
+
|
|
179
|
+
`DEFAULT_PAGE_SIZE`, `MAX_PAGE_SIZE` and `MAX_ASSERTED_ROWS` are exported, beside
|
|
180
|
+
`N_PLUS_ONE_THRESHOLD` and for the same reason: an action validating its own `pageSize` input
|
|
181
|
+
against a hardcoded `10_000` is a second declaration of one number, and the second one goes stale.
|
|
182
|
+
|
|
183
|
+
## Iterating every row
|
|
184
|
+
|
|
185
|
+
`As of 2026-08`. A page is bounded on purpose, so reading a whole table is a loop — and the loop is
|
|
186
|
+
the terminal, not something the caller writes around `page()`:
|
|
187
|
+
|
|
188
|
+
```ts
|
|
189
|
+
// One statement per batch, one page of rows in memory at a time.
|
|
190
|
+
for await (const batch of db.posts.where({ orgId }).preload('author').inBatches(500)) {
|
|
191
|
+
await search.index(batch);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Stopping early is cheap: the position survives, so the next run resumes where this one stopped.
|
|
195
|
+
await using batches = db.posts.where({ orgId }).after(checkpoint).inBatches(500);
|
|
196
|
+
for await (const batch of batches) {
|
|
197
|
+
await search.index(batch);
|
|
198
|
+
if (ctx.clock.now() > deadline) break;
|
|
199
|
+
}
|
|
200
|
+
await db.checkpoints.update(id, { cursor: batches.cursor });
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Every batch is the page `page()` would have returned at that position — same filters, same tenancy,
|
|
204
|
+
same soft-delete visibility, same `select()`, same `preload()` — so there is no second read path to
|
|
205
|
+
learn or to drift.
|
|
206
|
+
|
|
207
|
+
| | |
|
|
208
|
+
|---|---|
|
|
209
|
+
| Statements | one per batch, each asking for one row past it, exactly as `page()` does. An empty batch is never yielded |
|
|
210
|
+
| Position | keyset, never OFFSET: a row written mid-iteration cannot make the loop skip or repeat one. `after(cursor)` starts it, `.cursor` is where it stopped, `null` once exhausted |
|
|
211
|
+
| Closing | `break`, `return` and a throw all stop the next statement; `await using` is the same guarantee for a handle kept in a variable, and `close()` is idempotent. One handle is one iteration — a second `for await` continues it rather than restarting the table |
|
|
212
|
+
| Refusals | on the chain, not one batch later: a size that is not a whole number of rows between 1 and `MAX_PAGE_SIZE` (10,000), a `limit()` on the same chain (one number, two meanings), and an ordering no cursor can carry — a nullable sort column, which a result that fits in one batch would otherwise hide until the table grew |
|
|
213
|
+
| Tenancy | the plan's, as everywhere else: an unscoped chain is `X_TENANCY_UNSCOPED` on its first batch |
|
|
214
|
+
|
|
215
|
+
## Counting by a column
|
|
216
|
+
|
|
217
|
+
`As of 2026-08`. `count()` answers one number, so a screen or a backfill that needs one per row
|
|
218
|
+
asks N times. `countBy(column)` is that whole loop as one statement, keyed by the value:
|
|
219
|
+
|
|
220
|
+
```ts
|
|
221
|
+
// One statement for every post in `ids`, not one `select count(*)` each.
|
|
222
|
+
const counts = await db.likes.where({ orgId }).andWhere('postId', 'in', ids).countBy('postId');
|
|
223
|
+
for (const id of ids) await db.posts.update(id, { likeCount: counts.get(id) ?? 0 });
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
`ReadonlyMap<Row[K], number>`, keyed by the column named — the chain knows the row, so
|
|
227
|
+
`counts.get(postId)` is a `number | undefined` and the `undefined` is load-bearing.
|
|
228
|
+
|
|
229
|
+
| | |
|
|
230
|
+
|---|---|
|
|
231
|
+
| Counts | the whole predicate, exactly as `count()` does: the chain's filters, its tenancy and its soft-delete visibility. `limit()` and `after()` bound the page, never the count |
|
|
232
|
+
| A value nothing matched | absent, never `0` — that is what `group by` returns, and it is what tells "none" apart from "never asked". The default is the caller's `?? 0` |
|
|
233
|
+
| NULL | one group, keyed `null`, in both drivers. `0`, `''` and `false` stay the values they are |
|
|
234
|
+
| Order | biggest group first, ties by the value (numbers and bigints numerically, everything else by its text), `null` last — applied after the rows are in, since a hash aggregate and a `Map` filled row by row have no order to inherit |
|
|
235
|
+
| Groupable columns | `uuid`, `text`, `char`, `boolean`, `integer`, `bigint`. A timestamp, a `jsonb` or `money` is `X_INVARIANT_VIOLATED` naming one of this entity's columns that is: a `Map` compares a non-primitive key by identity, so such a map could only ever answer `undefined` |
|
|
236
|
+
| More than 1000 groups | `X_INVARIANT_VIOLATED`, never a truncated map — the statement asks for one group past the bound, exactly as a page reads one row past its limit. The `fix` spells the `andWhere('<column>', 'in', <values>)` that bounds it |
|
|
237
|
+
| Statement | `select "post_id" as group_value, count(*) as group_count … group by "post_id"`. Both names are fixed aliases, so an entity may still declare a column called `count`, and the grouped value is re-parsed by the column that declared it |
|
|
238
|
+
|
|
239
|
+
## Writing by filter
|
|
240
|
+
|
|
241
|
+
`deleteWhere`/`updateWhere` are the bulk forms of `delete`/`update` — the same fix `insertAll` is
|
|
242
|
+
for a per-row insert loop, applied to the two write shapes a composite-key entity cannot address
|
|
243
|
+
one row at a time: a `for … of` deleting or patching one row per iteration is one statement here,
|
|
244
|
+
not `n`.
|
|
245
|
+
|
|
246
|
+
```ts
|
|
247
|
+
db.posts.delete(id); // by a single primary key
|
|
248
|
+
db.posts.update(id, { title }); // ditto
|
|
249
|
+
|
|
250
|
+
db.likes.deleteWhere({ postId, userId }); // -> 1 · the only way to unlike
|
|
251
|
+
db.likes.deleteWhere({ postId }); // -> n · every like on that post
|
|
252
|
+
db.participants.updateWhere({ conversationId, userId }, { lastReadAt }); // -> 1 · mark read
|
|
253
|
+
|
|
254
|
+
db.likes.deleteWhere({}); // X_WRITE_UNFILTERED, never every row
|
|
255
|
+
db.participants.updateWhere({ conversationId }, {}); // X_PATCH_EMPTY, never a silent no-op
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
`delete(id)` and `update(id, patch)` both need a single-column primary key. A composite one —
|
|
259
|
+
`likes`, `blocks`, `participants`, any join table — has no single id, so the filtered pair is the
|
|
260
|
+
only write path there: without them such an entity is **create-only**, and a row could be written
|
|
261
|
+
and never unwritten.
|
|
262
|
+
|
|
263
|
+
| | |
|
|
264
|
+
|---|---|
|
|
265
|
+
| Returns | the **number of rows affected**, so "nothing matched" is distinguishable from "it worked" |
|
|
266
|
+
| Empty filter | `X_WRITE_UNFILTERED`. An `undefined` value is dropped before the count, so a forgotten variable is the error and not the whole table |
|
|
267
|
+
| Empty patch | `X_PATCH_EMPTY`. Counting rows for a statement that set nothing is the same silent no-op, one argument along |
|
|
268
|
+
| Tenancy | the plan a read builds, through `scopedPlan` — the actor's org predicate is in the statement, and the empty-filter guard runs before it, because one tenant's every row is still every row. The patch is judged too, through `assertRowTenant`: a filter bounds which rows are written, never what they become |
|
|
269
|
+
| Soft delete | the entity's `deletedAt` column is the same switch `delete(id)` uses. Stamped rows are not matched again by either call, so the original deletion time survives and a deleted row is never patched back into shape |
|
|
270
|
+
| `onUpdateNow()` | stamped by `touch()`, the same helper `update(id, patch)` uses — one place, so the two can never disagree about `updatedAt` |
|
|
271
|
+
| Rows read back | **only when something can still refuse them.** A `check` or a `unique` invariant is a constraint Postgres enforced on the statement, so the answer is a count and the statement carries no `returning *`. Only a JS-only rule (`kind: 'assert'`, `sql: null`) has to be judged on the result — and then the match is counted first and refused past `MAX_ASSERTED_ROWS` (50,000), naming `inBatches(1000)`, because a refusal issued after `returning *` is already holding what it is refusing |
|
|
272
|
+
|
|
273
|
+
## Writing many rows
|
|
274
|
+
|
|
275
|
+
```ts
|
|
276
|
+
await db.tags.insertAll(names.map((name) => ({ orgId, name }))); // one statement, n rows
|
|
277
|
+
|
|
278
|
+
await db.likes.upsertAll(rows, { // insert, or leave what is there
|
|
279
|
+
onConflict: ['orgId', 'postId', 'memberId'],
|
|
280
|
+
onMatch: 'nothing',
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
await db.counters.upsertAll(rows, { onConflict: ['orgId', 'day'] }); // insert, or overwrite
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
`insertAll` is `insert` in bulk and nothing else: rows are `Insertable`, each one goes through
|
|
287
|
+
`$parse`, so declared defaults are filled here rather than by the caller. `upsertAll` adds the one
|
|
288
|
+
thing a per-row loop cannot do without a read first — resolve a collision — and stamps
|
|
289
|
+
`onUpdateNow()` columns through the same `touch()` `update(id, patch)` uses, because an upsert that
|
|
290
|
+
lands on a stored row *is* an update.
|
|
291
|
+
|
|
292
|
+
Both resolve with **the rows this call wrote**, in order. Under `onMatch: 'nothing'` a row already
|
|
293
|
+
stored is skipped and absent from the result, exactly as `returning *` reports it — which is how a
|
|
294
|
+
caller counts what it actually inserted.
|
|
295
|
+
|
|
296
|
+
| | |
|
|
297
|
+
|---|---|
|
|
298
|
+
| One builder | `insertStatement` compiles every insert in the framework, so `insertAll([row])` is the text `insert(row)` always produced. There is no second insert path to drift |
|
|
299
|
+
| What a collision overwrites | every column the batch writes, minus the conflict target (how the row was found), minus the primary key (where it lives) and minus the soft-delete stamp (whether the row is there at all). Moving either of the first two moves a row nobody asked to move, and every foreign key pointing at that id misses it |
|
|
300
|
+
| A soft-deleted row it lands on | stays deleted, and takes the batch's other columns. The stamped row still occupies its conflict target — that index is not partial — so `excluded."deleted_at"` would resurrect it; `$parse` fills `deletedAt: null` into every row before the plan is built, so the stamp is dropped from the set list rather than refused. `insertAll` still writes the stamp a new row carries |
|
|
301
|
+
| Conflict target | properties of a **declared** unique constraint — the primary key, a `unique()` column, an `indexes: [{ on, unique: true }]` entry, or an `invariant(name, c.unique([…]))`. Anything else is `X_INVARIANT_VIOLATED` here rather than `42P10` from the server |
|
|
302
|
+
| Tenancy | on a tenant-scoped entity `onMatch: 'update'` requires the tenant column *in the conflict target*, else `X_TENANCY_UNSCOPED`: a target that omits it matches another tenant's row and rewrites it. `'nothing'` is allowed — it writes nothing to a row it does not own |
|
|
303
|
+
| A batch that repeats itself | two rows with one conflict target under `'update'` is refused. Postgres answers that statement `ON CONFLICT DO UPDATE command cannot affect row a second time`, so it cannot pass in memory either |
|
|
304
|
+
| Uneven batches | under `'update'` every row must name the same columns: `excluded.<column>` for a row that omitted one is that column's *default*, not "leave it alone". Under `'nothing'` and under `insertAll`, an omitted column is `default` in its cell, which is what the same row means on its own |
|
|
305
|
+
| Nulls | a null in the conflict target collides with nothing, in both drivers — a Postgres unique index is `NULLS DISTINCT` |
|
|
306
|
+
| Size | past 65535 bind parameters the batch is several statements, never one the server refuses. Wrap the call in `withTransaction` when all-or-nothing matters |
|
|
307
|
+
| Filtered writes | `updateWhere` / `deleteWhere` above are the bulk forms of `update` and `delete` — one statement for a loop that would otherwise call either per row; there is no `updateAll` |
|
|
308
|
+
|
|
309
|
+
## Relations are the foreign keys, read twice
|
|
310
|
+
|
|
311
|
+
```ts
|
|
312
|
+
relationMap().posts;
|
|
313
|
+
// { org: { kind: 'belongsTo', to: 'orgs', localKey: 'orgId', remoteKey: 'id' },
|
|
314
|
+
// author: { kind: 'belongsTo', to: 'members', localKey: 'authorId', remoteKey: 'id' },
|
|
315
|
+
// likes: { kind: 'hasMany', to: 'likes', localKey: 'id', remoteKey: 'postId' } }
|
|
316
|
+
|
|
317
|
+
relationsFor('posts'); // one entity's relations, by name
|
|
318
|
+
relationNamed('posts', 'author'); // one relation, or X_PRELOAD_UNKNOWN_RELATION listing the rest
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
`.references(() => members.id)` already says a post has an author and a member has posts. There
|
|
322
|
+
is no second declaration syntax for associations and there will not be one: the map reads the keys
|
|
323
|
+
that exist. `belongsTo` comes from an entity's own foreign keys, `hasMany` from the inbound ones —
|
|
324
|
+
so the two sides can never disagree, and neither can drift from the constraint the migration emits.
|
|
325
|
+
|
|
326
|
+
| Rule | Detail |
|
|
327
|
+
|---|---|
|
|
328
|
+
| Names | `authorId` ⇒ `author`; a `hasMany` is named for the entity the rows come from |
|
|
329
|
+
| Collisions | two keys wanting one name ⇒ **both** take the long form (`author` / `authorId`, `postsByAuthor` / `postsByReviewer`), so a name never depends on declaration order |
|
|
330
|
+
| Ambiguity | two keys that differ only by an `Id` suffix ⇒ `X_INVARIANT_VIOLATED` naming both columns, never one relation silently swallowing the other |
|
|
331
|
+
| Unknown name | `X_PRELOAD_UNKNOWN_RELATION`, whose `fix` is a `relationNamed()` call on one that exists plus the rest by name — they are derived, so there is no schema file listing them to go and read |
|
|
332
|
+
| Keys | `local*` is always on `from`, `remote*` on `to`, whichever side the edge is read from |
|
|
333
|
+
| Money | no relation: one property, three physical columns, so none of them is the key |
|
|
334
|
+
|
|
335
|
+
**Where they come from.** `RegistryEntry.references()` — every `entity()` call leaves one behind, so
|
|
336
|
+
a consumer walks the whole domain without importing a schema module. A method rather than a field:
|
|
337
|
+
a `references()` thunk may point at an entity that two modules of an import cycle have not finished
|
|
338
|
+
evaluating. `relationMap()` derives the whole registry and memoises against its generation, so a
|
|
339
|
+
schema module imported late is rebuilt into the map instead of being missed by it, and a read that
|
|
340
|
+
changed nothing costs one integer compare. `relationsOf(entries)` is the same derivation over a
|
|
341
|
+
named subset — a `belongsTo` to an entity outside it is still reported, a `hasMany` needs both
|
|
342
|
+
sides. An entity holding its own keys reads `entity.$references()`: same closure, so a foreign key
|
|
343
|
+
is read once and not twice.
|
|
344
|
+
|
|
345
|
+
`preload()`, below, is what consumes it — exported because the derivation is a fact about the
|
|
346
|
+
schema, not an implementation detail of whoever traverses it first.
|
|
347
|
+
|
|
348
|
+
## Preloading a relation
|
|
349
|
+
|
|
350
|
+
`As of 2026-08`. A single `findById` batches itself, and a `for … of` loop over a page batches the
|
|
351
|
+
loop it causes — reach for `preload()` to carry the relation from the start, without waiting on
|
|
352
|
+
either pattern to trigger it:
|
|
353
|
+
|
|
354
|
+
```ts
|
|
355
|
+
export const db = database({ orgs, posts, members });
|
|
356
|
+
|
|
357
|
+
// Two statements: the page, then one `select … where "id" in (…)` over its authors.
|
|
358
|
+
const page = await db.posts.where({ orgId }).preload('author').page();
|
|
359
|
+
page.rows[0].author; // the member row, or null — always present
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
| | |
|
|
363
|
+
|---|---|
|
|
364
|
+
| Vocabulary | one method, `preload('<relation>')` — no `include`, `join` or `with` |
|
|
365
|
+
| Unknown name | `X_PRELOAD_UNKNOWN_RELATION` at `preload()` itself, not a page later |
|
|
366
|
+
| Shape | `belongsTo` attaches the row or `null`; `hasMany` an array — always present |
|
|
367
|
+
| Statements | one extra per relation, resolved concurrently; naming one twice is one statement |
|
|
368
|
+
| Tenancy | carried onto the related read only when the other entity's tenant column shares the name; otherwise `X_TENANCY_UNSCOPED` refuses the related read rather than guess |
|
|
369
|
+
| Terminals | `page()`, `all()`, `one()` preload; `count()`, `countBy()` and `plan()` don't — none reads a row to attach one to |
|
|
370
|
+
|
|
104
371
|
## Two drivers, one meaning
|
|
105
372
|
|
|
106
373
|
```ts
|
|
@@ -113,6 +380,23 @@ database({ orgs, posts }, { driver: postgresDriver() }); // production
|
|
|
113
380
|
| Rows live | in a `Map` | in Postgres |
|
|
114
381
|
| For | tests, `x dev` before the first migration | production |
|
|
115
382
|
| Transaction | `memoryTransactor()` — undo closures | `postgresTransactor()` — real `BEGIN`/`COMMIT` |
|
|
383
|
+
| `reset()` | empties every repository it built | not implemented — the rows are the app's |
|
|
384
|
+
|
|
385
|
+
`database()` called with no driver takes the process default, and `defaultDriver()` is that same
|
|
386
|
+
object — the one seam a test harness needs, `As of 2026-08`:
|
|
387
|
+
|
|
388
|
+
```ts
|
|
389
|
+
import { defaultDriver } from '@ultimat3/entity';
|
|
390
|
+
|
|
391
|
+
defaultDriver().repo(posts).insert(row); // seeds what database({ posts }) reads
|
|
392
|
+
defaultDriver().reset?.(); // between tests; `?.` because Postgres has no reset
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
`Driver.reset?()` is optional on the interface and implemented by `memoryDriver()` alone, so a
|
|
396
|
+
harness asks rather than assumes. It empties the repositories in place: `database()` resolves each
|
|
397
|
+
table's repository once, so a driver that swapped in fresh ones would leave every handle the app
|
|
398
|
+
already holds reading the old rows. Application code never calls either — it names its driver in
|
|
399
|
+
`database(entities, { driver })`, or takes the default implicitly.
|
|
116
400
|
|
|
117
401
|
They are not two implementations of an idea. They share the plan (scope, sort order, page size),
|
|
118
402
|
the cursor codec and the `Repo<T>` contract, so a page taken in a test means the same thing as a
|
|
@@ -121,29 +405,196 @@ returns the open transaction when there is one, so a repository call inside `wit
|
|
|
121
405
|
joins it without being told — which is how a job's outbox row lands atomically with the write
|
|
122
406
|
that enqueued it.
|
|
123
407
|
|
|
408
|
+
`postgresDriver({ client })` pins one instead, for a test harness or `x db branch` — and a pinned
|
|
409
|
+
repository used while a transaction is open is `X_REPO_CLIENT_PINNED`, `As of 2026-08`.
|
|
410
|
+
`withTransaction` reserved a connection and ran `BEGIN` on it; a pinned repository sends straight
|
|
411
|
+
to its own client, so the write would commit whatever the transaction decides and the read would
|
|
412
|
+
miss what the transaction has written, both silently. It is refused rather than resolved because a
|
|
413
|
+
`DbTx` does not name the client it was opened on: on a sharded app "the same connection" and "the
|
|
414
|
+
same database" are two different questions and this layer can answer neither. `setDbClient(client)`
|
|
415
|
+
plus an unpinned repository is the shape that joins.
|
|
416
|
+
|
|
124
417
|
Every value is bound to `$n` and every identifier is resolved through the entity, so a column
|
|
125
418
|
name can only be one the entity declared and a row value can never become SQL.
|
|
126
419
|
|
|
420
|
+
## Point lookups batch themselves
|
|
421
|
+
|
|
422
|
+
`As of 2026-08`:
|
|
423
|
+
|
|
424
|
+
```ts
|
|
425
|
+
const repo = postgresRepo(users);
|
|
426
|
+
// One statement, not one per post: the lookups issued in this microtask are one `in`.
|
|
427
|
+
const authors = await Promise.all(posts.map((post) => repo.findById(post.authorId, { orgId })));
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
`findById` keeps its signature and gets faster. There is no `dataloader()`, no `batch()` and
|
|
431
|
+
nothing to opt into: inside a request the point lookups issued in one microtask become one
|
|
432
|
+
`select … where "id" in ($1, $2, …)` carrying the scope each of them carried, and outside one — a
|
|
433
|
+
job, a script — it is the single statement it always was.
|
|
434
|
+
|
|
435
|
+
| | |
|
|
436
|
+
|---|---|
|
|
437
|
+
| Window | one microtask, closed before the statement is sent |
|
|
438
|
+
| Lifetime | the request; the batch is keyed by context identity and dies with it |
|
|
439
|
+
| Never shared with | another tenant, another soft-delete visibility, another projection, another entity, another client |
|
|
440
|
+
| Wider than 500 ids | several whole statements, never one Postgres refuses |
|
|
441
|
+
| An id with no row | `null`, exactly as the single statement answered |
|
|
442
|
+
|
|
443
|
+
## A page batches the loop it causes
|
|
444
|
+
|
|
445
|
+
`As of 2026-08`. A sequential loop shares no microtask — its `await` ends the window before the
|
|
446
|
+
next lookup exists. So a page leaves its foreign key values behind, and the first lookup for any
|
|
447
|
+
one of them resolves that key for every row of the page:
|
|
448
|
+
|
|
449
|
+
```ts
|
|
450
|
+
const page = await postgresRepo(posts).findMany({ orgId });
|
|
451
|
+
for (const post of page.rows) {
|
|
452
|
+
// Two statements for the whole loop: the page, then one `in` over every author on it.
|
|
453
|
+
const author = await postgresRepo(users).findById(post.authorId, { orgId });
|
|
454
|
+
}
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
Nothing new to write: the relation is the `references()` the column already declares.
|
|
458
|
+
|
|
459
|
+
| | |
|
|
460
|
+
|---|---|
|
|
461
|
+
| Served to | a lookup with the same scope key, the same client, and no write to that entity since — the preload statement *is* the statement it was widened from |
|
|
462
|
+
| Scope | the tenant predicate and `deleted_at is null` are in the preload statement, so a page's ids can never resolve rows outside the reader's own scope |
|
|
463
|
+
| A write | drops what was preloaded for that entity, before the statement goes out, so a changed row is re-read and never served from before it |
|
|
464
|
+
| Held | the ids, never the rows; keyed by context identity, so it dies with the request |
|
|
465
|
+
| Declines to the old statement | no request in scope, an id no page indexed, a key that resolved to nothing |
|
|
466
|
+
| Switched off | `postgresDriver({ jitPreload: false })`, where the driver is constructed — the one switch. Not an `app.config.ts` key: nothing reads config at the seam that builds a repository |
|
|
467
|
+
|
|
468
|
+
## A loop that got past all of that is reported, with the fix already written
|
|
469
|
+
|
|
470
|
+
`As of 2026-08`. The three batching paths above are what a loop *should* have taken; `nPlusOne()`
|
|
471
|
+
is what an author is handed when it did not. It takes a repeated statement — the verdict a ledger
|
|
472
|
+
reached, never a count this package keeps — and returns the error every surface renders:
|
|
473
|
+
|
|
474
|
+
```ts
|
|
475
|
+
nPlusOne({ kind: 'read', subject: 'members.findById', count: 50, entity: 'members', op: 'findById' });
|
|
476
|
+
// X_N_PLUS_ONE_QUERY: a read repeated once per row
|
|
477
|
+
// cause: members.findById ran 50 times in one request — one read per row
|
|
478
|
+
// fix: db.posts.preload('author') # one statement for the whole page
|
|
479
|
+
```
|
|
480
|
+
|
|
481
|
+
The relation in that `fix` is derived, never invented: `preloadsFor(entity, op)` reads the same
|
|
482
|
+
`relationMap()` `preload()` resolves against, so the line pastes into a chain that already
|
|
483
|
+
compiles. Which edge answers which loop follows from the operation — a point lookup per row is the
|
|
484
|
+
`belongsTo` side (`posts.preload('author')`), a filtered read per row the `hasMany` side
|
|
485
|
+
(`posts.preload('comments')`), and every other operation falls back to the batched form of the
|
|
486
|
+
statement that repeated.
|
|
487
|
+
|
|
488
|
+
| The loop | The `fix` |
|
|
489
|
+
|---|---|
|
|
490
|
+
| `findById` / `findMany` with a relation pointing at it | `db.<page>.preload('<relation>')`, the first candidate pasteable and the rest listed — the ledger saw the statement, never the `for … of` above it |
|
|
491
|
+
| a read with no such relation, or an operation no preload answers | `db.<entity>.andWhere('id', 'in', ids).all()` |
|
|
492
|
+
| `insert` / `update` / `delete` per row | `db.<entity>.insertAll(rows)` / `.updateWhere(filter, patch)` / `.deleteWhere(filter)` |
|
|
493
|
+
| hand-written SQL, attributed to no entity | the statement's own `any($1)` form, or `expectedQueryLoop('<why>', fn)` |
|
|
494
|
+
|
|
495
|
+
Nothing here counts or installs anything: `x dev` owns the ledger, `@ultimat3/testing`'s `statements`
|
|
496
|
+
fixture owns the strict one, `expectedQueryLoop` from `@ultimat3/db` is the one way to declare a loop
|
|
497
|
+
deliberate, and a production process pays the one branch the observer seam costs uninstalled. The
|
|
498
|
+
one number both detectors read *is* here — `N_PLUS_ONE_THRESHOLD` (5), next to the codes whose `fix`
|
|
499
|
+
it triggers, so a loop that fails a test and a loop that warns in dev are the same loop.
|
|
500
|
+
|
|
127
501
|
## Tenancy is a guard
|
|
128
502
|
|
|
129
503
|
`tenant: 'orgId'` on the entity names the column outright. Omit it and it is inferred — a
|
|
130
504
|
`.tenant()` column, else one named `orgId` — so an entity never becomes unscoped by forgetting the
|
|
131
505
|
key; name a column that does not exist and the declaration fails with `X_INVARIANT_VIOLATED`.
|
|
132
506
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
507
|
+
**A tenant column may not be nullable**, whichever of the three switches named it, and the
|
|
508
|
+
declaration fails the same way. `assertRowTenant` leaves a row that names no tenant alone and
|
|
509
|
+
delegates to the column's `NOT NULL`; on a nullable column that delegation has nothing behind it, so
|
|
510
|
+
the row lands with a null tenant, is matched by no `org_id = $1`, and belongs to nobody — never in
|
|
511
|
+
an export, never in an offboarding sweep, and there for as long as the table is.
|
|
512
|
+
|
|
513
|
+
**The tenant is the acting actor's, and never an argument.** Inside a request every plan for a
|
|
514
|
+
scoped entity is scoped to `ctx.actor.orgId`, whether the call named a tenant or not — so
|
|
515
|
+
`db.posts.where({ status })` reads one org's posts and a handler no longer threads a tenant
|
|
516
|
+
through its own signatures. Writes are reads: `update(id, patch)` and `delete(id)` build the same
|
|
517
|
+
plan, so an id alone never addresses a row, and another tenant's id is `X_NOT_FOUND` rather than
|
|
518
|
+
theirs.
|
|
519
|
+
|
|
520
|
+
An `orgId` argument is still legal and now means "I assert this is the tenant": equal to the
|
|
521
|
+
actor's it is a restatement, different from it — an `orgId` that arrived as action input, a query
|
|
522
|
+
string or a path parameter — it is `X_TENANCY_ACTOR_MISMATCH`, refused rather than silently
|
|
523
|
+
overridden, with both values in the cause. `in` on the tenant column is judged the same way: a set
|
|
524
|
+
containing the actor's org is still a set that is not it.
|
|
525
|
+
|
|
526
|
+
| Situation | Answer |
|
|
527
|
+
|---|---|
|
|
528
|
+
| actor carries an `orgId` | the plan is scoped to it, derived |
|
|
529
|
+
| a call names the same one | a restatement; one predicate, not two |
|
|
530
|
+
| a call names another one | `X_TENANCY_ACTOR_MISMATCH` |
|
|
531
|
+
| actor carries no `orgId` (anonymous, or a service actor minted without one) | `X_TENANCY_ACTOR_ORG_REQUIRED` — inside no org, every tenant-scoped row is somebody else's |
|
|
532
|
+
| no request context at all (a script, a seed, a test harness) | there is no actor to derive from, so the caller names the tenant and `X_TENANCY_UNSCOPED` refuses a plan that names none |
|
|
533
|
+
| a read that must span tenants | `crossTenant(reason, fn)` |
|
|
534
|
+
|
|
535
|
+
**A row is judged the same way as a predicate.** `insert`, `insertAll` and `upsertAll` build no
|
|
536
|
+
read plan at all, and a patch decides what a row *becomes*, so the tenant a write names is checked
|
|
537
|
+
against the actor too: `insert({ orgId: theirs, … })` and `update(id, { orgId: theirs })` are both
|
|
538
|
+
`X_TENANCY_ACTOR_MISMATCH`, refused before the statement is sent and before anything is stored. A
|
|
539
|
+
batch is all or nothing — one bad row refuses the rows beside it, in both drivers.
|
|
540
|
+
|
|
541
|
+
**Refused, never stamped.** A row that names no tenant is left exactly as it was written: the
|
|
542
|
+
column's own `NOT NULL` answers a missing one. Filling it in from the actor is the ergonomic half
|
|
543
|
+
and it is deliberately absent, because the column list an `upsertAll` writes is decided by which
|
|
544
|
+
properties a row names — a stamped column would change the statement, silence the uneven-batch
|
|
545
|
+
refusal, and let ambient state decide which stored row a collision lands on.
|
|
546
|
+
|
|
547
|
+
**A cross-tenant upsert is unrepresentable rather than documented.** Two halves, and both are now
|
|
548
|
+
enforced: the conflict target must contain the tenant column under `onMatch: 'update'`
|
|
549
|
+
(`X_TENANCY_UNSCOPED` — the target is what decides which stored row a collision lands on), and
|
|
550
|
+
every incoming row must name the acting actor's tenant. Together, the key a collision is judged by
|
|
551
|
+
can only hold a value that is this actor's.
|
|
552
|
+
|
|
553
|
+
```ts
|
|
554
|
+
// admin surfaces, background reconciliation, support tooling — greppable, and never a boolean
|
|
555
|
+
await crossTenant('nightly invite expiry runs for every org', async () => { … });
|
|
556
|
+
```
|
|
557
|
+
|
|
558
|
+
The scope needs the `tenancy:cross` capability on the actor (`scopes: ['tenancy:cross']`), proven
|
|
559
|
+
at the call and again at every plan built inside it, so an impersonated child context cannot
|
|
560
|
+
inherit it: `X_TENANCY_CROSS_DENIED` otherwise. A blank reason is refused — an escape with no
|
|
561
|
+
argument is a pragma. There is no build-time tenancy check in `x verify`, and there cannot
|
|
562
|
+
usefully be one: the tenant is a request-time value, so the seam every plan is built through is
|
|
563
|
+
the enforcement.
|
|
136
564
|
|
|
137
565
|
## Seeds
|
|
138
566
|
|
|
139
|
-
`defineSeed(
|
|
140
|
-
|
|
141
|
-
the invariants, which makes a seed a test of the schema as well.
|
|
567
|
+
`defineSeed(name, build, { tier })` — the fixture graph, written once and **replayed anywhere**: a
|
|
568
|
+
second run writes nothing and raises nothing, against Postgres as well as against memory. Rows go
|
|
569
|
+
through the columns and the invariants either way, which makes a seed a test of the schema as well.
|
|
570
|
+
`x db seed [<name>]` is what applies it.
|
|
571
|
+
|
|
572
|
+
Two write verbs, because only the author knows which key identifies a row:
|
|
573
|
+
|
|
574
|
+
| Context member | Use it when | A replay |
|
|
575
|
+
|---|---|---|
|
|
576
|
+
| `insert(entity, rows)` | the seed chose the ids — `id('post:tenancy')` is a UUID v5 of the label, so the same graph gets the same ids on every machine | one `on conflict … do nothing` statement per call; a stored row is left alone and counted `skipped` |
|
|
577
|
+
| `upsert(entity, { by, preserve? }, values)` | the table owns the id and only a natural key identifies the row | reads first, so an unchanged row is `'skipped'` with no statement; otherwise one `on conflict … do update`, which settles the race between two containers booting at once |
|
|
578
|
+
| `exists(entity, where?)` / `count(entity, where?)` | bulk volume data, where the FILE is the unit of idempotency | the sentinel returns early and nothing is written |
|
|
579
|
+
| `deleteWhere(entity, where)` | a scoped wipe before a regenerate | **refused on a soft-deleting entity**: the stamp keeps the row's unique key and no replay can clear it |
|
|
580
|
+
| `id`, `now`, `environment`, `tier`, `dryRun`, `metrics` | — | `now` is one instant per run; `metrics` is `{ inserted, updated, skipped }` and `run()` returns it |
|
|
581
|
+
|
|
582
|
+
Rules worth knowing before the first seed: `upsert` never overwrites `createdAt` (`preserve` names
|
|
583
|
+
other columns to spare); `upsert` on a tenant-scoped entity needs the tenant column inside `by`,
|
|
584
|
+
exactly as `upsertAll` does, while `insert` needs nothing because `do nothing` writes nothing to a
|
|
585
|
+
row it does not own; and `insert` refuses a row that leaves a `uuid().primaryKey()` unnamed —
|
|
586
|
+
`$parse` would fill it with a fresh uuid and every replay would insert one more copy.
|
|
587
|
+
|
|
588
|
+
`tier` is `'dev'` (the default, fixture data) or `'reference'` (data the app is wrong without,
|
|
589
|
+
which ships to production). The refusal is the CLI's, never `run()`'s: an app that seeds its own
|
|
590
|
+
database from its boot code has decided to, and a library that overruled that would break it.
|
|
142
591
|
|
|
143
592
|
## Errors
|
|
144
593
|
|
|
145
|
-
`X_ENTITY_DUPLICATE` · `X_INVARIANT_VIOLATED` · `X_TENANCY_UNSCOPED` ·
|
|
146
|
-
`
|
|
594
|
+
`X_ENTITY_DUPLICATE` · `X_INVARIANT_VIOLATED` · `X_TENANCY_UNSCOPED` ·
|
|
595
|
+
`X_TENANCY_ACTOR_MISMATCH` · `X_TENANCY_ACTOR_ORG_REQUIRED` · `X_TENANCY_CROSS_DENIED` ·
|
|
596
|
+
`X_DB_DRIFT` · `X_NOT_FOUND` · `X_WRITE_UNFILTERED` · `X_PATCH_EMPTY` ·
|
|
597
|
+
`X_PRELOAD_UNKNOWN_RELATION` · `X_N_PLUS_ONE_QUERY` · `X_N_PLUS_ONE_WRITE`
|
|
147
598
|
|
|
148
599
|
## Boundaries
|
|
149
600
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/entity",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": "A table + its domain type + invariants the database also enforces",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"files": [
|
|
20
20
|
"src",
|
|
21
21
|
"!src/**/*.test.ts",
|
|
22
|
+
"CLAUDE.md",
|
|
22
23
|
"README.md",
|
|
23
24
|
"LICENSE"
|
|
24
25
|
],
|
|
@@ -30,8 +31,9 @@
|
|
|
30
31
|
"test": "bun test"
|
|
31
32
|
},
|
|
32
33
|
"dependencies": {
|
|
33
|
-
"@ultimat3/core": "
|
|
34
|
-
"@ultimat3/db": "
|
|
35
|
-
"@ultimat3/schema": "
|
|
34
|
+
"@ultimat3/core": "3.0.0",
|
|
35
|
+
"@ultimat3/db": "3.0.0",
|
|
36
|
+
"@ultimat3/schema": "3.0.0",
|
|
37
|
+
"@ultimat3/time": "3.0.0"
|
|
36
38
|
}
|
|
37
39
|
}
|