@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.
Files changed (67) hide show
  1. package/CLAUDE.md +66 -96
  2. package/README.md +125 -8
  3. package/package.json +15 -5
  4. package/src/append-only.ts +7 -0
  5. package/src/batch-read.ts +12 -4
  6. package/src/bulk-write-send.ts +49 -0
  7. package/src/bulk-write.ts +48 -11
  8. package/src/column.ts +29 -4
  9. package/src/counter.ts +109 -0
  10. package/src/cross-tenant.ts +13 -0
  11. package/src/cursor.ts +17 -1
  12. package/src/database.ts +16 -10
  13. package/src/describe-tenancy.ts +25 -0
  14. package/src/describe.ts +146 -3
  15. package/src/entity-error.ts +33 -0
  16. package/src/entity-parse.ts +89 -0
  17. package/src/entity-patch.ts +42 -0
  18. package/src/entity.ts +95 -98
  19. package/src/errors.ts +8 -0
  20. package/src/history-actor.ts +22 -0
  21. package/src/history.ts +86 -0
  22. package/src/index-identity.ts +46 -0
  23. package/src/index-init.ts +12 -0
  24. package/src/index.ts +20 -1
  25. package/src/jit-preload.ts +35 -25
  26. package/src/lifecycle.ts +100 -0
  27. package/src/lock-key.ts +27 -0
  28. package/src/lock-unsupported.ts +11 -0
  29. package/src/memory-aggregates.ts +99 -0
  30. package/src/memory-batch.ts +69 -0
  31. package/src/memory-journal.ts +68 -0
  32. package/src/memory-lifecycle.ts +106 -0
  33. package/src/memory-lock.ts +144 -0
  34. package/src/memory-observed.ts +121 -0
  35. package/src/memory-repo.ts +199 -186
  36. package/src/memory-same-tenant.ts +83 -0
  37. package/src/memory-seek.ts +44 -0
  38. package/src/memory-transactor.ts +94 -0
  39. package/src/memory-unique.ts +19 -4
  40. package/src/n-plus-one.ts +28 -10
  41. package/src/numeric-compare.ts +43 -0
  42. package/src/pg-aggregates.ts +185 -0
  43. package/src/pg-driver.ts +79 -166
  44. package/src/pg-lock.ts +50 -0
  45. package/src/pg-row.ts +29 -2
  46. package/src/pg-sql-aggregate.ts +31 -0
  47. package/src/pg-sql.ts +17 -1
  48. package/src/query.ts +26 -105
  49. package/src/read-aggregates.ts +138 -0
  50. package/src/read-capture.ts +22 -0
  51. package/src/registry.ts +49 -1
  52. package/src/repo.ts +34 -0
  53. package/src/retain.ts +71 -0
  54. package/src/row-observer.ts +22 -4
  55. package/src/row-security-settings.ts +20 -0
  56. package/src/sealed-repo.ts +15 -0
  57. package/src/seed.ts +43 -7
  58. package/src/series-unsupported.ts +11 -0
  59. package/src/table-locks.ts +41 -0
  60. package/src/test-driver.ts +90 -0
  61. package/src/time-bucket.ts +147 -0
  62. package/src/touch.ts +33 -0
  63. package/src/types.ts +22 -0
  64. package/src/view-entity.ts +78 -0
  65. package/src/view-issue.ts +47 -0
  66. package/src/view-repo.ts +38 -0
  67. 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
+ }
@@ -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 type { ColumnMap, Insertable } from './types';
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
- const found = await repo.findMany({ where: equalityPredicates(where), limit: 1 });
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
- await repo.upsertAll([stored === undefined ? row : withoutPreserved(row, preserve)], {
340
- ...undo,
341
- onConflict: key.by,
342
- onMatch: 'update',
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
+ }