@ultimat3/entity 29.3.2 → 30.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 +66 -96
- package/README.md +125 -8
- package/package.json +15 -5
- package/src/append-only.ts +7 -0
- package/src/batch-read.ts +12 -4
- package/src/bulk-write-send.ts +49 -0
- package/src/bulk-write.ts +48 -11
- package/src/column.ts +29 -4
- package/src/counter.ts +109 -0
- package/src/cross-tenant.ts +13 -0
- package/src/cursor.ts +17 -1
- package/src/database.ts +16 -10
- package/src/describe-tenancy.ts +25 -0
- package/src/describe.ts +146 -3
- package/src/entity-error.ts +33 -0
- package/src/entity-parse.ts +89 -0
- package/src/entity-patch.ts +42 -0
- package/src/entity.ts +95 -98
- package/src/errors.ts +8 -0
- package/src/history-actor.ts +22 -0
- package/src/history.ts +86 -0
- package/src/index-identity.ts +46 -0
- package/src/index-init.ts +12 -0
- package/src/index.ts +20 -1
- package/src/jit-preload.ts +35 -25
- package/src/lifecycle.ts +100 -0
- package/src/lock-key.ts +27 -0
- package/src/lock-unsupported.ts +11 -0
- package/src/memory-aggregates.ts +99 -0
- package/src/memory-batch.ts +69 -0
- package/src/memory-journal.ts +68 -0
- package/src/memory-lifecycle.ts +106 -0
- package/src/memory-lock.ts +144 -0
- package/src/memory-observed.ts +121 -0
- package/src/memory-repo.ts +199 -186
- package/src/memory-same-tenant.ts +83 -0
- package/src/memory-seek.ts +44 -0
- package/src/memory-transactor.ts +94 -0
- package/src/memory-unique.ts +19 -4
- package/src/n-plus-one.ts +28 -10
- package/src/numeric-compare.ts +43 -0
- package/src/pg-aggregates.ts +185 -0
- package/src/pg-driver.ts +79 -166
- package/src/pg-lock.ts +50 -0
- package/src/pg-row.ts +29 -2
- package/src/pg-sql-aggregate.ts +31 -0
- package/src/pg-sql.ts +17 -1
- package/src/query.ts +26 -105
- package/src/read-aggregates.ts +138 -0
- package/src/read-capture.ts +22 -0
- package/src/registry.ts +49 -1
- package/src/repo.ts +34 -0
- package/src/retain.ts +71 -0
- package/src/row-observer.ts +22 -4
- package/src/row-security-settings.ts +20 -0
- package/src/sealed-repo.ts +15 -0
- package/src/seed.ts +43 -7
- package/src/series-unsupported.ts +11 -0
- package/src/table-locks.ts +41 -0
- package/src/test-driver.ts +90 -0
- package/src/time-bucket.ts +147 -0
- package/src/touch.ts +33 -0
- package/src/types.ts +22 -0
- package/src/view-entity.ts +78 -0
- package/src/view-issue.ts +47 -0
- package/src/view-repo.ts +38 -0
- package/src/view.ts +41 -27
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Single responsibility: the tenant every transaction carries when the database enforces row
|
|
2
|
+
// security — the same `tenant:` the repository already scopes by, handed to `@ultimat3/db`'s
|
|
3
|
+
// transaction-settings seam. A `crossTenant()` scope sets `x.cross`; no actor tenant sets nothing.
|
|
4
|
+
|
|
5
|
+
import { tryUseContext } from '@ultimat3/core';
|
|
6
|
+
import type { LocalSettings } from '@ultimat3/db';
|
|
7
|
+
import { crossTenantReason } from './cross-tenant';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Read once per root transaction, at its BEGIN. Two limits, stated: a `crossTenant()` opened INSIDE
|
|
11
|
+
* an open transaction does not reach that transaction (its settings were fixed at BEGIN), and a
|
|
12
|
+
* raw `set_config('x.cross', 'on', true)` in the app's own process lifts the guard — this layer
|
|
13
|
+
* catches forgetting, not hostile SQL already running inside the process.
|
|
14
|
+
*/
|
|
15
|
+
export function rowSecuritySettings(): LocalSettings | undefined {
|
|
16
|
+
if (crossTenantReason() !== undefined) return { 'x.cross': 'on' };
|
|
17
|
+
const orgId = tryUseContext()?.actor.orgId;
|
|
18
|
+
if (orgId === undefined || orgId === null || orgId === '') return undefined;
|
|
19
|
+
return { 'x.tenant': String(orgId) };
|
|
20
|
+
}
|
package/src/sealed-repo.ts
CHANGED
|
@@ -232,5 +232,20 @@ export const sealedRepo = <Row, R extends Repo<Row>>(entity: SealedSource, repo:
|
|
|
232
232
|
return repo.approximateCount(await readArgs(entity, fields, args, sealKeysOnce()));
|
|
233
233
|
},
|
|
234
234
|
};
|
|
235
|
+
// A bucket is an instant and a sum is arithmetic: neither has a meaning over ciphertext.
|
|
236
|
+
const series = repo.series?.bind(repo);
|
|
237
|
+
if (series !== undefined) {
|
|
238
|
+
sealing.series = async (spec, args) => {
|
|
239
|
+
const named = spec.value === 'count' ? [spec.on] : [spec.on, spec.value.sum];
|
|
240
|
+
refuseNamed(entity, fields, named, 'an aggregate');
|
|
241
|
+
return series(spec, await readArgs(entity, fields, args, sealKeysOnce()));
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
// Opened before `fn` sees it: a callback is a caller, and `lockKey` hands it no row to open.
|
|
245
|
+
const inner = repo.lock?.bind(repo);
|
|
246
|
+
if (inner !== undefined) {
|
|
247
|
+
sealing.lock = (id, fn, options) =>
|
|
248
|
+
inner(id, async (row, tx) => fn(await openRow(fields, row, sealKeysOnce()), tx), options);
|
|
249
|
+
}
|
|
235
250
|
return { ...repo, ...sealing };
|
|
236
251
|
};
|
package/src/seed.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { createHash } from 'node:crypto';
|
|
7
7
|
import { type Environment, resolveEnvironment } from '@ultimat3/core';
|
|
8
|
+
import { overwrittenBy } from './bulk-write';
|
|
8
9
|
import { entityNow } from './clock';
|
|
9
10
|
import type { Driver } from './database';
|
|
10
11
|
import { memoryDriver } from './database';
|
|
@@ -12,7 +13,8 @@ import { type EntityCore, SOFT_DELETE_COLUMN } from './entity';
|
|
|
12
13
|
import { EntityError } from './errors';
|
|
13
14
|
import type { Tx } from './repo';
|
|
14
15
|
import type { Predicate } from './tenancy';
|
|
15
|
-
import
|
|
16
|
+
import { stampedColumns, touch } from './touch';
|
|
17
|
+
import type { ColumnMap, Insertable, RowPatch } from './types';
|
|
16
18
|
|
|
17
19
|
/** Framework namespace for seed labels. Fixed forever: changing it moves every seeded id. */
|
|
18
20
|
const NAMESPACE = 'a3c1f0d6-5c2b-4a3e-9f1b-6d4e7c8a9b02';
|
|
@@ -201,6 +203,12 @@ const sameCell = (left: unknown, right: unknown): boolean => {
|
|
|
201
203
|
return left === right;
|
|
202
204
|
};
|
|
203
205
|
|
|
206
|
+
/** A soft-deleting entity's row its delete has stamped — what a plain read reads as absent. */
|
|
207
|
+
const isStamped = (row: unknown): boolean => {
|
|
208
|
+
const stamp = cellOf(row, SOFT_DELETE_COLUMN);
|
|
209
|
+
return stamp !== null && stamp !== undefined;
|
|
210
|
+
};
|
|
211
|
+
|
|
204
212
|
const equalityPredicates = <Row>(where: Partial<Row>): readonly Predicate[] =>
|
|
205
213
|
Object.entries(where).map(([column, value]): Predicate => ({ column, op: 'eq', value }));
|
|
206
214
|
|
|
@@ -253,6 +261,16 @@ const withoutPreserved = <Row>(row: Row, preserve: readonly string[]): Row => {
|
|
|
253
261
|
return copy as Row;
|
|
254
262
|
};
|
|
255
263
|
|
|
264
|
+
/** What a replay overwrites: the columns the seed named and the `onUpdateNow()` ones, never `preserve`. */
|
|
265
|
+
const seedOverwrites = <Row>(
|
|
266
|
+
entity: EntityCore<Row>,
|
|
267
|
+
values: unknown,
|
|
268
|
+
preserve: readonly string[],
|
|
269
|
+
): readonly (keyof Row & string)[] =>
|
|
270
|
+
[...overwrittenBy(entity, [values as RowPatch<Row>]), ...stampedColumns(entity)].filter(
|
|
271
|
+
(property) => !preserve.includes(property),
|
|
272
|
+
) as (keyof Row & string)[];
|
|
273
|
+
|
|
256
274
|
export const defineSeed = (
|
|
257
275
|
name: string,
|
|
258
276
|
build: (context: SeedContext) => Promise<void>,
|
|
@@ -300,8 +318,20 @@ export const defineSeed = (
|
|
|
300
318
|
const where = Object.fromEntries(
|
|
301
319
|
key.by.map((property) => [property, cellOf(row, property)]),
|
|
302
320
|
);
|
|
303
|
-
|
|
321
|
+
// `includeDeleted`: a stamped row is still the row the key names. Read without it, it
|
|
322
|
+
// was absent, the write landed on it with `deleted_at` spared, and every replay said
|
|
323
|
+
// `'inserted'` for a row nobody can see. A soft delete is a decision a seed replayed on
|
|
324
|
+
// every boot does not undo — skipped, as `insert`'s do-nothing conflict skips it.
|
|
325
|
+
const found = await repo.findMany({
|
|
326
|
+
where: equalityPredicates(where),
|
|
327
|
+
limit: 1,
|
|
328
|
+
includeDeleted: true,
|
|
329
|
+
});
|
|
304
330
|
const stored = found.rows[0];
|
|
331
|
+
if (stored !== undefined && entity.$softDelete && isStamped(stored)) {
|
|
332
|
+
metrics.skipped += 1;
|
|
333
|
+
return 'skipped';
|
|
334
|
+
}
|
|
305
335
|
const preserve: readonly string[] = key.preserve ?? [CREATED_AT_COLUMN];
|
|
306
336
|
// Only what the CALLER named, and never a key the table generates: `$parse` fills a
|
|
307
337
|
// fresh uuid and a `defaultNow()` into `row`, which no stored row can equal — so a
|
|
@@ -336,11 +366,17 @@ export const defineSeed = (
|
|
|
336
366
|
return outcome;
|
|
337
367
|
}
|
|
338
368
|
const write: SeedWrite = stored === undefined ? 'inserted' : 'updated';
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
369
|
+
// What the caller named, as `db.*.upsertAll` does: `$parse` filled the rest, and a
|
|
370
|
+
// re-run must not reset a column this seed never mentioned to its default.
|
|
371
|
+
await repo.upsertAll(
|
|
372
|
+
[touch(entity, stored === undefined ? row : withoutPreserved(row, preserve))],
|
|
373
|
+
{
|
|
374
|
+
...undo,
|
|
375
|
+
onConflict: key.by,
|
|
376
|
+
onMatch: 'update',
|
|
377
|
+
overwrites: seedOverwrites(entity, values, preserve),
|
|
378
|
+
},
|
|
379
|
+
);
|
|
344
380
|
metrics[write === 'inserted' ? 'inserted' : 'updated'] += 1;
|
|
345
381
|
return write;
|
|
346
382
|
},
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// The refusal a hand-written `Repo` answers `series` with. It is optional on the contract so such a
|
|
2
|
+
// driver still compiles, which is exactly why the typed handle has to say so — as `lock` does.
|
|
3
|
+
|
|
4
|
+
import { EntityError } from './entity-error';
|
|
5
|
+
|
|
6
|
+
export const seriesUnsupported = (entityName: string): EntityError =>
|
|
7
|
+
new EntityError({
|
|
8
|
+
code: 'X_ENTITY_SERIES_UNSUPPORTED',
|
|
9
|
+
cause: `${entityName}.series() reached a repository that does not implement it: a hand-written driver, which the contract lets omit it`,
|
|
10
|
+
fix: 'database(entities, { driver: postgresDriver() }) # both shipped drivers answer series; a hand-written Repo implements series(spec, args)',
|
|
11
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Single responsibility: `lock` and `lockKey` on the typed handle. The refusal lives here because
|
|
2
|
+
// the repository contract lets a hand-written driver omit both, so only this layer can say so.
|
|
3
|
+
|
|
4
|
+
import { lockUnsupported } from './lock-unsupported';
|
|
5
|
+
import type { Repo, RepoOptions, Tx } from './repo';
|
|
6
|
+
import type { IdOf } from './types';
|
|
7
|
+
|
|
8
|
+
export interface TableLocks<Row> {
|
|
9
|
+
/**
|
|
10
|
+
* `db.posts.lock(id, async (post, tx) => db.posts.update(id, next(post), { tx }))`: the row, locked
|
|
11
|
+
* for `fn`, so a read-modify-write loses no update. `fn`'s answer is the answer; a throw rolls back.
|
|
12
|
+
* Absent or another tenant's is `X_NOT_FOUND`. Nested in `withTransaction` the lock lasts to the
|
|
13
|
+
* root COMMIT. No wait option: the bound is the role's `statement_timeout`, and a deadlock is
|
|
14
|
+
* `X_DB_SERIALIZATION_FAILURE`.
|
|
15
|
+
*/
|
|
16
|
+
lock<R>(
|
|
17
|
+
id: IdOf<Row>,
|
|
18
|
+
fn: (row: Row, tx: Tx) => Promise<R> | R,
|
|
19
|
+
options?: RepoOptions,
|
|
20
|
+
): Promise<R>;
|
|
21
|
+
/**
|
|
22
|
+
* `fn` alone among callers of `key` in this table and this tenant, for work no one row names. Org
|
|
23
|
+
* A's key never blocks org B. On a tenant-scoped entity with no actor it is `X_TENANCY_UNSCOPED`.
|
|
24
|
+
*/
|
|
25
|
+
lockKey<R>(key: string, fn: (tx: Tx) => Promise<R> | R, options?: RepoOptions): Promise<R>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Called as methods and refused in an `async` body, so a class-shaped driver keeps its `this`. */
|
|
29
|
+
export const tableLocks = <Row>(
|
|
30
|
+
entity: { readonly $name: string },
|
|
31
|
+
repo: Repo<Row>,
|
|
32
|
+
): TableLocks<Row> => ({
|
|
33
|
+
lock: async (id, fn, options) => {
|
|
34
|
+
if (repo.lock === undefined) throw lockUnsupported(entity.$name, 'lock');
|
|
35
|
+
return repo.lock(id, fn, options);
|
|
36
|
+
},
|
|
37
|
+
lockKey: async (key, fn, options) => {
|
|
38
|
+
if (repo.lockKey === undefined) throw lockUnsupported(entity.$name, 'lockKey');
|
|
39
|
+
return repo.lockKey(key, fn, options);
|
|
40
|
+
},
|
|
41
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// The driver a scaffolded handle takes under test: memory until `setDbClient` installs a client,
|
|
2
|
+
// Postgres through that client while it is. Decided per call, because `database()` resolves each
|
|
3
|
+
// table's repository once, at module load, long before a test file installs its migrated database.
|
|
4
|
+
|
|
5
|
+
import { asyncContext } from '@ultimat3/core';
|
|
6
|
+
import { dbClientInstalled } from '@ultimat3/db';
|
|
7
|
+
import { type Driver, memoryDriver } from './database';
|
|
8
|
+
import type { EntityCore } from './entity';
|
|
9
|
+
import { testDriverClientChanged } from './errors';
|
|
10
|
+
import { postgresDriver } from './pg-driver';
|
|
11
|
+
import type { Repo, Transactor } from './repo';
|
|
12
|
+
|
|
13
|
+
type Side = 'memory' | 'postgres';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The side an open `transactor().run` began on. Inside it a repository call takes that side, and a
|
|
17
|
+
* client installed or cleared meanwhile is refused: following it would split one transaction's
|
|
18
|
+
* writes across two stores, the one committed or rolled back without the other.
|
|
19
|
+
*/
|
|
20
|
+
const began = asyncContext<Side>('a testDriver() transaction');
|
|
21
|
+
|
|
22
|
+
const installedSide = (): Side => (dbClientInstalled() ? 'postgres' : 'memory');
|
|
23
|
+
|
|
24
|
+
const sideFor = (entityName: string): Side => {
|
|
25
|
+
const now = installedSide();
|
|
26
|
+
const open = began.get();
|
|
27
|
+
if (open !== undefined && open !== now) throw testDriverClientChanged(entityName, open);
|
|
28
|
+
return now;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* One repository per entity whose every method asks `pick()` at call time. A plain object of
|
|
33
|
+
* forwarders rather than a `Proxy`: `observedRepo` spreads the repository, and a spread copies the
|
|
34
|
+
* values a `Proxy` answers at that moment — the target would be frozen at module load. Keyed off
|
|
35
|
+
* both repositories, so a method `Repo` gains later forwards with no edit here; `reset` is memory's
|
|
36
|
+
* alone and stays on the driver.
|
|
37
|
+
*/
|
|
38
|
+
const following = <Row>(name: string, memory: Repo<Row>, postgres: Repo<Row>): Repo<Row> => {
|
|
39
|
+
const pick = (): Repo<Row> => (sideFor(name) === 'postgres' ? postgres : memory);
|
|
40
|
+
const forwarded: Record<string, unknown> = {};
|
|
41
|
+
for (const key of Object.keys(postgres)) {
|
|
42
|
+
if (typeof Reflect.get(memory, key) !== 'function') continue;
|
|
43
|
+
forwarded[key] = (...args: unknown[]): unknown => {
|
|
44
|
+
let target: Repo<Row>;
|
|
45
|
+
try {
|
|
46
|
+
target = pick();
|
|
47
|
+
} catch (refusal) {
|
|
48
|
+
// A repository call rejects, never throws synchronously — every `Repo` method is async.
|
|
49
|
+
return Promise.reject(refusal);
|
|
50
|
+
}
|
|
51
|
+
const method: unknown = Reflect.get(target, key);
|
|
52
|
+
// Both sides carried a function under this key when the forwarder was built.
|
|
53
|
+
return (method as (...a: unknown[]) => unknown).apply(target, args);
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
// Built from the keys both repositories carry, each forwarding to the same-named method.
|
|
57
|
+
return forwarded as unknown as Repo<Row>;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* `storeMode(env) === 'memory' ? testDriver() : postgresDriver()` — the scaffolded handle's line.
|
|
62
|
+
* A test file that never installs a client is exactly the memory driver: no PGlite is loaded, no
|
|
63
|
+
* statement is sent. One that follows `wiki/Testing.md`'s recipe (`setDbClient(migrated.client)`,
|
|
64
|
+
* then `setDbClient(undefined)` in `afterAll`) reads and writes its migrated database through the
|
|
65
|
+
* same `db` the code under test imports — joins and raw SQL included.
|
|
66
|
+
*/
|
|
67
|
+
export const testDriver = (): Driver => {
|
|
68
|
+
const memory = memoryDriver();
|
|
69
|
+
const postgres = postgresDriver();
|
|
70
|
+
const repos = new Map<string, unknown>();
|
|
71
|
+
return {
|
|
72
|
+
repo<Row>(entity: EntityCore<Row>): Repo<Row> {
|
|
73
|
+
const existing = repos.get(entity.$name);
|
|
74
|
+
// Keyed by entity name and only written from that entity, as `memoryDriver` keys its own.
|
|
75
|
+
if (existing !== undefined) return existing as Repo<Row>;
|
|
76
|
+
const created = following(entity.$name, memory.repo(entity), postgres.repo(entity));
|
|
77
|
+
repos.set(entity.$name, created);
|
|
78
|
+
return created;
|
|
79
|
+
},
|
|
80
|
+
// Memory only: the installed database's rows are the test's own, emptied by its own template.
|
|
81
|
+
reset: () => memory.reset?.(),
|
|
82
|
+
transactor: (): Transactor => ({
|
|
83
|
+
run: (work) => {
|
|
84
|
+
const side = installedSide();
|
|
85
|
+
const target = side === 'postgres' ? postgres : memory;
|
|
86
|
+
return began.run(side, () => target.transactor().run(work));
|
|
87
|
+
},
|
|
88
|
+
}),
|
|
89
|
+
};
|
|
90
|
+
};
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// Single responsibility: what a time series is made of — the closed list of units, the zone check,
|
|
2
|
+
// the bucket ceiling, and the one in-memory truncation Postgres' three-argument `date_trunc` is
|
|
3
|
+
// matched against. Both drivers read the rules from here, as `count-by.ts` is read for a grouped
|
|
4
|
+
// count, so a `series` against memory buckets a row exactly where Postgres buckets it.
|
|
5
|
+
|
|
6
|
+
import { renderFixShellArg } from '@ultimat3/core';
|
|
7
|
+
import { assertTimeZone, fromZoned, instant, isValidTimeZone, toZoned } from '@ultimat3/time';
|
|
8
|
+
import { aggregateColumnOf } from './aggregate';
|
|
9
|
+
import { columnFor } from './column';
|
|
10
|
+
import { MAX_GROUPS } from './count-by';
|
|
11
|
+
import type { EntityCore } from './entity';
|
|
12
|
+
import { EntityError } from './entity-error';
|
|
13
|
+
import type { AnyColumn } from './types';
|
|
14
|
+
|
|
15
|
+
/** Closed. A week starts on Monday, as `date_trunc('week', …)` starts one. */
|
|
16
|
+
export const SERIES_UNITS = ['minute', 'hour', 'day', 'week', 'month'] as const;
|
|
17
|
+
export type SeriesUnit = (typeof SERIES_UNITS)[number];
|
|
18
|
+
|
|
19
|
+
/** `'count'` per bucket, or one column summed per bucket — `aggregate.ts`'s `sum`, never a float. */
|
|
20
|
+
export type SeriesValue<K extends string = string> = 'count' | { readonly sum: K };
|
|
21
|
+
|
|
22
|
+
/** The row-agnostic request a driver answers; `ReadBuilder.series` is the typed spelling. */
|
|
23
|
+
export interface SeriesSpec {
|
|
24
|
+
readonly on: string;
|
|
25
|
+
readonly unit: SeriesUnit;
|
|
26
|
+
/** IANA, required: a bucket in the session's zone is a bucket in nobody's. */
|
|
27
|
+
readonly timeZone: string;
|
|
28
|
+
readonly value: SeriesValue;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** One bucket that holds rows. A bucket with none is absent — the range is the caller's. */
|
|
32
|
+
export interface SeriesPoint<V = unknown> {
|
|
33
|
+
/** The bucket's first instant: local midnight, the local hour, the local Monday… */
|
|
34
|
+
readonly bucket: Date;
|
|
35
|
+
readonly value: V;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A spec both drivers have judged: the zone canonical, the columns resolved. */
|
|
39
|
+
export interface SeriesPlan {
|
|
40
|
+
readonly on: string;
|
|
41
|
+
readonly unit: SeriesUnit;
|
|
42
|
+
readonly timeZone: string;
|
|
43
|
+
readonly sum: { readonly property: string; readonly column: AnyColumn } | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const UNITS: ReadonlySet<string> = new Set(SERIES_UNITS);
|
|
47
|
+
|
|
48
|
+
const seriesInvalid = (entityName: string, detail: string, fix: string): EntityError =>
|
|
49
|
+
new EntityError({
|
|
50
|
+
code: 'X_ENTITY_SERIES_INVALID',
|
|
51
|
+
cause: `${entityName}.series(): ${detail}`,
|
|
52
|
+
fix,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const timestampColumns = <Row>(entity: EntityCore<Row>): readonly string[] =>
|
|
56
|
+
Object.entries(entity.$columns)
|
|
57
|
+
.filter(([, column]) => column.$meta.kind === 'timestamptz')
|
|
58
|
+
.map(([property]) => property);
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The spec, judged before any statement exists — by the same function in both drivers, so a
|
|
62
|
+
* `date()` column or `'EST'` is refused with the same words whichever one is attached.
|
|
63
|
+
*/
|
|
64
|
+
export const seriesPlanOf = <Row>(entity: EntityCore<Row>, spec: SeriesSpec): SeriesPlan => {
|
|
65
|
+
const name = entity.$name;
|
|
66
|
+
const [first = 'createdAt'] = timestampColumns(entity);
|
|
67
|
+
const example = `${name}.series({ on: '${first}', unit: 'day', timeZone: 'Europe/Berlin', value: 'count' })`;
|
|
68
|
+
if (!UNITS.has(spec.unit)) {
|
|
69
|
+
throw seriesInvalid(
|
|
70
|
+
name,
|
|
71
|
+
`unit '${String(spec.unit)}' is not one of ${SERIES_UNITS.join(', ')}`,
|
|
72
|
+
`${example} # unit is one of: ${SERIES_UNITS.join(', ')}`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
const on: AnyColumn | undefined = columnFor(entity.$columns, spec.on);
|
|
76
|
+
if (on?.$meta.kind !== 'timestamptz') {
|
|
77
|
+
throw seriesInvalid(
|
|
78
|
+
name,
|
|
79
|
+
on === undefined
|
|
80
|
+
? `no column "${spec.on}" on ${name}`
|
|
81
|
+
: `"${spec.on}" is a ${on.$meta.kind} column, and a bucket is a span of instants`,
|
|
82
|
+
timestampColumns(entity).length === 0
|
|
83
|
+
? `x entities show ${renderFixShellArg(name, '<entity>')} --json # this entity declares no timestamp() column to bucket by`
|
|
84
|
+
: `${example} # on is one of: ${timestampColumns(entity).join(', ')}`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
if (typeof spec.timeZone !== 'string' || !isValidTimeZone(spec.timeZone)) {
|
|
88
|
+
throw seriesInvalid(
|
|
89
|
+
name,
|
|
90
|
+
`timeZone ${JSON.stringify(String(spec.timeZone))} is not an IANA zone`,
|
|
91
|
+
`${example} # an IANA zone such as Europe/Berlin or Asia/Tokyo, never an abbreviation or an offset`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
const value = spec.value;
|
|
95
|
+
const sum =
|
|
96
|
+
value === 'count'
|
|
97
|
+
? null
|
|
98
|
+
: { property: value.sum, column: aggregateColumnOf(entity, 'sum', value.sum) };
|
|
99
|
+
return { on: spec.on, unit: spec.unit, timeZone: assertTimeZone(spec.timeZone), sum };
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const MINUTE_MS = 60_000;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The bucket an instant falls in, as Postgres' `date_trunc(unit, ts, zone)` answers it — measured
|
|
106
|
+
* on 18, the rule is two rules:
|
|
107
|
+
*
|
|
108
|
+
* - `minute`/`hour` truncate the wall clock and keep the instant's OWN offset, so the repeated hour
|
|
109
|
+
* of a fall-back day is two buckets, and a `+05:30` zone's hour starts at :30 UTC.
|
|
110
|
+
* - `day`/`week`/`month` truncate to local midnight and resolve it afresh: a midnight that does
|
|
111
|
+
* not exist is read at the offset before the jump (`gap: 'next'`), and one that happens twice at
|
|
112
|
+
* the offset after it (`overlap: 'second'`) — Asia/Gaza's 2016-10-29 starts at 22:00Z.
|
|
113
|
+
*/
|
|
114
|
+
export const bucketStart = (at: Date, unit: SeriesUnit, timeZone: string): Date => {
|
|
115
|
+
const zoned = toZoned(instant(at), timeZone);
|
|
116
|
+
if (unit === 'minute' || unit === 'hour') {
|
|
117
|
+
// The wall clock's own fields, subtracted from the instant: the offset never moves, which is
|
|
118
|
+
// the rule, and a zone off the minute (pre-1972 Monrovia) is read at its real offset.
|
|
119
|
+
const within = zoned.second * 1000 + zoned.millisecond;
|
|
120
|
+
return new Date(at.getTime() - within - (unit === 'hour' ? zoned.minute * MINUTE_MS : 0));
|
|
121
|
+
}
|
|
122
|
+
const day = unit === 'week' ? zoned.day - (zoned.weekday - 1) : unit === 'month' ? 1 : zoned.day;
|
|
123
|
+
return fromZoned({ year: zoned.year, month: zoned.month, day, hour: 0, minute: 0 }, timeZone, {
|
|
124
|
+
gap: 'next',
|
|
125
|
+
overlap: 'second',
|
|
126
|
+
});
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The points both drivers hand back: ascending by bucket, refused past the ceiling. The statement
|
|
131
|
+
* asks for one bucket more than `MAX_GROUPS`, and that one is a refusal rather than a dropped tail:
|
|
132
|
+
* a chart missing its last thousand days reads exactly like a complete one.
|
|
133
|
+
*/
|
|
134
|
+
export const seriesFrom = <Row>(
|
|
135
|
+
entity: EntityCore<Row>,
|
|
136
|
+
plan: SeriesPlan,
|
|
137
|
+
points: readonly SeriesPoint[],
|
|
138
|
+
): readonly SeriesPoint[] => {
|
|
139
|
+
if (points.length > MAX_GROUPS) {
|
|
140
|
+
throw seriesInvalid(
|
|
141
|
+
entity.$name,
|
|
142
|
+
`more than ${MAX_GROUPS} ${plan.unit} buckets matched — that is a report, not a chart`,
|
|
143
|
+
`${entity.$name}.andWhere('${plan.on}', 'gte', <from>).series({ on: '${plan.on}', unit: '${plan.unit}', … }) # bound the range, or a wider unit`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
return [...points].sort((left, right) => left.bucket.getTime() - right.bucket.getTime());
|
|
147
|
+
};
|
package/src/touch.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Single responsibility: what a patch or an upserted row writes beyond what the caller named — the
|
|
2
|
+
// `onUpdateNow()` stamp. Split from `query.ts` for its line ceiling.
|
|
3
|
+
|
|
4
|
+
import { entityNow } from './clock';
|
|
5
|
+
import type { EntityCore } from './entity';
|
|
6
|
+
import { namedColumns } from './plan';
|
|
7
|
+
import { copyRow } from './sealed';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Columns declared `onUpdateNow()` are written by the framework, never by the caller. One helper,
|
|
11
|
+
* so `update(id, patch)` and `updateWhere(filter, patch)` stamp the same columns at the same
|
|
12
|
+
* moment — a second copy is how one of them ends up with a stale `updatedAt`.
|
|
13
|
+
*
|
|
14
|
+
* A patch that names nothing is returned untouched. Stamping `updatedAt` onto "the caller named no
|
|
15
|
+
* columns" would turn that mistake into a real write on any entity that happens to declare the
|
|
16
|
+
* column, and `X_PATCH_EMPTY` downstream would never see it — so whether the refusal fires would
|
|
17
|
+
* depend on the schema rather than on the call.
|
|
18
|
+
*/
|
|
19
|
+
export const touch = <Row, Patch>(entity: EntityCore<Row>, patch: Patch): Patch => {
|
|
20
|
+
if (namedColumns(patch).length === 0) return patch;
|
|
21
|
+
const stamped: Record<string, unknown> = {};
|
|
22
|
+
for (const [property, column] of Object.entries(entity.$columns)) {
|
|
23
|
+
if (column.$meta.onUpdate !== undefined) stamped[property] = entityNow();
|
|
24
|
+
}
|
|
25
|
+
// `copyRow`, never `Object.assign({}, patch)`: a row passed back whole keeps its sealed properties.
|
|
26
|
+
return Object.assign(copyRow(patch as object), stamped) as Patch;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** The columns `touch` stamps: an upsert's collision moves them though no caller named them. */
|
|
30
|
+
export const stampedColumns = <Row>(entity: EntityCore<Row>): readonly string[] =>
|
|
31
|
+
Object.entries(entity.$columns)
|
|
32
|
+
.filter(([, column]) => column.$meta.onUpdate !== undefined)
|
|
33
|
+
.map(([property]) => property);
|
package/src/types.ts
CHANGED
|
@@ -57,6 +57,19 @@ export type SearchWeight = 'A' | 'B' | 'C' | 'D';
|
|
|
57
57
|
|
|
58
58
|
export interface ReferenceOptions {
|
|
59
59
|
readonly onDelete?: OnDelete;
|
|
60
|
+
/**
|
|
61
|
+
* Why this edge may point into another tenant. Both ends tenant-scoped is a composite same-tenant
|
|
62
|
+
* key by default; a non-blank reason here makes it a plain one. Stated on an edge that was never
|
|
63
|
+
* same-tenant it is refused: a reason for nothing.
|
|
64
|
+
*/
|
|
65
|
+
readonly crossTenant?: string;
|
|
66
|
+
/**
|
|
67
|
+
* The parent's `integer()` column the database keeps equal to the number of rows pointing at it,
|
|
68
|
+
* by a trigger on THIS table in the same transaction as the write (`counter.ts`). Declared here,
|
|
69
|
+
* on the key, because the child already names its parent — a thunk on the parent naming the child
|
|
70
|
+
* would make the two declarations each other's type and neither could be inferred.
|
|
71
|
+
*/
|
|
72
|
+
readonly countedIn?: () => AnyColumn;
|
|
60
73
|
}
|
|
61
74
|
|
|
62
75
|
/**
|
|
@@ -141,6 +154,8 @@ export interface ColumnMeta {
|
|
|
141
154
|
/** A thunk: schema modules reference each other in a cycle. */
|
|
142
155
|
readonly references?: () => AnyColumn;
|
|
143
156
|
readonly onDelete?: OnDelete;
|
|
157
|
+
/** `references(target, { crossTenant })`: the reason, stated once, read only at describe time. */
|
|
158
|
+
readonly crossTenant?: string;
|
|
144
159
|
/**
|
|
145
160
|
* Presence is what puts this column in the entity's generated `tsvector`, and the value is its
|
|
146
161
|
* weight. A modifier and not a column of its own: the vector is derived from every searchable
|
|
@@ -161,6 +176,11 @@ export interface ColumnMeta {
|
|
|
161
176
|
* that seals and opens; everything else reads it to refuse (`sealed-declaration.ts`).
|
|
162
177
|
*/
|
|
163
178
|
readonly sealed?: SealedMeta;
|
|
179
|
+
/**
|
|
180
|
+
* `references(() => posts.id, { countedIn: () => posts.likeCount })`: the parent integer the
|
|
181
|
+
* database keeps equal to the number of rows pointing there. `counter.ts` reads it.
|
|
182
|
+
*/
|
|
183
|
+
readonly countedIn?: () => AnyColumn;
|
|
164
184
|
}
|
|
165
185
|
|
|
166
186
|
/** What `.sealed(options)` declared, resolved. */
|
|
@@ -451,4 +471,6 @@ export interface IndexDef {
|
|
|
451
471
|
* one fact that this release exists to stop.
|
|
452
472
|
*/
|
|
453
473
|
readonly using?: IndexMethod;
|
|
474
|
+
/** `indexes: [{ concurrently: true }]` — `true` or absent, never `false` (`IndexInit`). */
|
|
475
|
+
readonly concurrently?: true;
|
|
454
476
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// `entity(name, { view: { sql } })`: what a view declaration may not hold, and the record it leaves
|
|
2
|
+
// on the entity. A view is a relation Postgres DERIVES — read like a table, written never — so the
|
|
3
|
+
// refusals here are the declarations that would promise a write, a trigger or an index Postgres
|
|
4
|
+
// has no place for. Writes are refused at the repository seam (`append-only.ts`).
|
|
5
|
+
|
|
6
|
+
import { EntityError } from './entity-error';
|
|
7
|
+
import type { IndexDef } from './types';
|
|
8
|
+
|
|
9
|
+
/** `entity(…, { view })`. `materialized` stores the rows; `refreshView()` recomputes them. */
|
|
10
|
+
export interface ViewInit {
|
|
11
|
+
/** One `select`, the view's whole body. Its output columns are `columns`' physical names. */
|
|
12
|
+
readonly sql: string;
|
|
13
|
+
readonly materialized?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** What `$viewQuery` holds: the query and the flag, `null` on every table. */
|
|
17
|
+
export interface ViewQuery {
|
|
18
|
+
readonly sql: string;
|
|
19
|
+
readonly materialized: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const refused = (entityName: string, detail: string, fix: string): EntityError =>
|
|
23
|
+
new EntityError({
|
|
24
|
+
code: 'X_INVARIANT_VIOLATED',
|
|
25
|
+
cause: `entity('${entityName}', { view }): ${detail}`,
|
|
26
|
+
fix,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export interface ViewDeclaration {
|
|
30
|
+
readonly appendOnly: boolean;
|
|
31
|
+
readonly indexes: readonly IndexDef[];
|
|
32
|
+
readonly sealed: readonly string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The view, judged where it was written. `;` is refused anywhere but the end: the body is spliced
|
|
37
|
+
* into `create view … as <sql>`, and a second statement there would run with the migration's
|
|
38
|
+
* privileges under a name nobody reviewed as DDL.
|
|
39
|
+
*/
|
|
40
|
+
export const viewQueryOf = (
|
|
41
|
+
entityName: string,
|
|
42
|
+
init: ViewInit | undefined,
|
|
43
|
+
declared: ViewDeclaration,
|
|
44
|
+
): ViewQuery | null => {
|
|
45
|
+
if (init === undefined) return null;
|
|
46
|
+
const sql = init.sql.trim().replace(/;\s*$/, '');
|
|
47
|
+
if (sql.length === 0 || sql.includes(';')) {
|
|
48
|
+
throw refused(
|
|
49
|
+
entityName,
|
|
50
|
+
sql.length === 0 ? 'the query is blank' : 'the query holds more than one statement',
|
|
51
|
+
`entity('${entityName}', { view: { sql: 'select … from <table>' }, columns }) # one select, no ;`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
const materialized = init.materialized === true;
|
|
55
|
+
if (declared.appendOnly) {
|
|
56
|
+
throw refused(
|
|
57
|
+
entityName,
|
|
58
|
+
'is appendOnly, and nothing writes to a view',
|
|
59
|
+
`entity('${entityName}', { view: { sql }, columns }) # drop appendOnly: true; the source table carries it`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
const [sealed] = declared.sealed;
|
|
63
|
+
if (sealed !== undefined) {
|
|
64
|
+
throw refused(
|
|
65
|
+
entityName,
|
|
66
|
+
`${sealed} is sealed, and a view's rows are computed by Postgres, which cannot open a sealed value`,
|
|
67
|
+
`entity('${entityName}', { view: { sql }, columns }) # remove .sealed() from ${sealed}; decrypt-and-group is meaningless`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
if (!materialized && declared.indexes.length > 0) {
|
|
71
|
+
throw refused(
|
|
72
|
+
entityName,
|
|
73
|
+
'declares an index, and Postgres has no index on a plain view',
|
|
74
|
+
`entity('${entityName}', { view: { sql, materialized: true }, columns }) # or drop the index and .unique()`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
return { sql, materialized };
|
|
78
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Single responsibility: one `$view` key's refusal as a schema issue — its path, a rule from the
|
|
2
|
+
// column's own fact and the column's own sentence — so a form binds it to the field it names.
|
|
3
|
+
// Split from `view.ts` so the projection stays one job.
|
|
4
|
+
|
|
5
|
+
import { renderThrowable, stringField } from '@ultimat3/core';
|
|
6
|
+
import type { SchemaNode, StandardIssue } from '@ultimat3/schema';
|
|
7
|
+
import type { AnyColumn } from './types';
|
|
8
|
+
|
|
9
|
+
/** `refuseColumn`'s cause: `column.<rule>: <sentence>`. */
|
|
10
|
+
const COLUMN_CAUSE = /^column\.([a-z-]+): (.+)$/s;
|
|
11
|
+
|
|
12
|
+
/** The rule a column fact names, from `@ultimat3/schema`'s `SCHEMA_RULES`. */
|
|
13
|
+
function ruleOf(
|
|
14
|
+
column: AnyColumn,
|
|
15
|
+
node: SchemaNode,
|
|
16
|
+
refused: string | undefined,
|
|
17
|
+
): Pick<StandardIssue, 'rule' | 'params'> {
|
|
18
|
+
const meta = column.$meta;
|
|
19
|
+
if (refused === 'length' && meta.length !== undefined) {
|
|
20
|
+
return { rule: 'string.max', params: { max: meta.length } };
|
|
21
|
+
}
|
|
22
|
+
if (meta.values !== undefined)
|
|
23
|
+
return { rule: 'enum', params: { values: meta.values.join(' | ') } };
|
|
24
|
+
return { rule: 'type', params: { type: node.kind } };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A required column with nothing at its key: missing data, never a value the view may invent. */
|
|
28
|
+
export function missingIssue(key: string): StandardIssue {
|
|
29
|
+
return { path: [key], message: 'is required', rule: 'required' };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* What a column's `$parse` threw, at `[key]`. The sentence is the column's own (`expected at most
|
|
34
|
+
* 200 characters, got 300`) — never the whole `X_INVARIANT_VIOLATED` line, which names the column
|
|
35
|
+
* a second time and reads as a server fault. A throwable that is not the column's refusal is
|
|
36
|
+
* rendered whole, through `renderThrowable`, which is total.
|
|
37
|
+
*/
|
|
38
|
+
export function columnIssue(
|
|
39
|
+
key: string,
|
|
40
|
+
column: AnyColumn,
|
|
41
|
+
node: SchemaNode,
|
|
42
|
+
error: unknown,
|
|
43
|
+
): StandardIssue {
|
|
44
|
+
const match = COLUMN_CAUSE.exec(stringField(error, 'cause') ?? '');
|
|
45
|
+
const message = match?.[2] ?? renderThrowable(error);
|
|
46
|
+
return { path: [key], message, ...ruleOf(column, node, match?.[1]) };
|
|
47
|
+
}
|