@ultimabase/postgres 0.0.0-stage → 0.1.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 (91) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +4 -2
  3. package/dist/adapter.d.ts +26 -0
  4. package/dist/adapter.d.ts.map +1 -0
  5. package/dist/adapter.js +254 -0
  6. package/dist/aggregate.d.ts +8 -0
  7. package/dist/aggregate.d.ts.map +1 -0
  8. package/dist/aggregate.js +138 -0
  9. package/dist/decode.d.ts +5 -0
  10. package/dist/decode.d.ts.map +1 -0
  11. package/dist/decode.js +30 -0
  12. package/dist/fills.d.ts +16 -0
  13. package/dist/fills.d.ts.map +1 -0
  14. package/dist/fills.js +37 -0
  15. package/dist/filter.d.ts +50 -0
  16. package/dist/filter.d.ts.map +1 -0
  17. package/dist/filter.js +247 -0
  18. package/dist/index.d.ts +15 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +11 -0
  21. package/dist/json-filter.d.ts +12 -0
  22. package/dist/json-filter.d.ts.map +1 -0
  23. package/dist/json-filter.js +65 -0
  24. package/dist/listen.d.ts +10 -0
  25. package/dist/listen.d.ts.map +1 -0
  26. package/dist/listen.js +92 -0
  27. package/dist/logic.d.ts +7 -0
  28. package/dist/logic.d.ts.map +1 -0
  29. package/dist/logic.js +18 -0
  30. package/dist/migrations.d.ts +23 -0
  31. package/dist/migrations.d.ts.map +1 -0
  32. package/dist/migrations.js +217 -0
  33. package/dist/names.d.ts +66 -0
  34. package/dist/names.d.ts.map +1 -0
  35. package/dist/names.js +175 -0
  36. package/dist/notify.d.ts +19 -0
  37. package/dist/notify.d.ts.map +1 -0
  38. package/dist/notify.js +75 -0
  39. package/dist/numbers.d.ts +13 -0
  40. package/dist/numbers.d.ts.map +1 -0
  41. package/dist/numbers.js +49 -0
  42. package/dist/parts.d.ts +36 -0
  43. package/dist/parts.d.ts.map +1 -0
  44. package/dist/parts.js +99 -0
  45. package/dist/plan-indexes.d.ts +12 -0
  46. package/dist/plan-indexes.d.ts.map +1 -0
  47. package/dist/plan-indexes.js +49 -0
  48. package/dist/plan-parts.d.ts +16 -0
  49. package/dist/plan-parts.d.ts.map +1 -0
  50. package/dist/plan-parts.js +9 -0
  51. package/dist/plan.d.ts +6 -0
  52. package/dist/plan.d.ts.map +1 -0
  53. package/dist/plan.js +278 -0
  54. package/dist/pool.d.ts +5 -0
  55. package/dist/pool.d.ts.map +1 -0
  56. package/dist/pool.js +41 -0
  57. package/dist/read.d.ts +47 -0
  58. package/dist/read.d.ts.map +1 -0
  59. package/dist/read.js +227 -0
  60. package/dist/scratch.d.ts +22 -0
  61. package/dist/scratch.d.ts.map +1 -0
  62. package/dist/scratch.js +77 -0
  63. package/dist/snapshot.d.ts +66 -0
  64. package/dist/snapshot.d.ts.map +1 -0
  65. package/dist/snapshot.js +272 -0
  66. package/dist/write.d.ts +17 -0
  67. package/dist/write.d.ts.map +1 -0
  68. package/dist/write.js +296 -0
  69. package/package.json +45 -3
  70. package/src/adapter.ts +349 -0
  71. package/src/aggregate.ts +168 -0
  72. package/src/decode.ts +46 -0
  73. package/src/fills.ts +52 -0
  74. package/src/filter.ts +306 -0
  75. package/src/index.ts +20 -0
  76. package/src/json-filter.ts +74 -0
  77. package/src/listen.ts +97 -0
  78. package/src/logic.ts +28 -0
  79. package/src/migrations.ts +275 -0
  80. package/src/names.ts +217 -0
  81. package/src/notify.ts +101 -0
  82. package/src/numbers.ts +64 -0
  83. package/src/parts.ts +143 -0
  84. package/src/plan-indexes.ts +100 -0
  85. package/src/plan-parts.ts +35 -0
  86. package/src/plan.ts +389 -0
  87. package/src/pool.ts +63 -0
  88. package/src/read.ts +287 -0
  89. package/src/scratch.ts +92 -0
  90. package/src/snapshot.ts +390 -0
  91. package/src/write.ts +384 -0
@@ -0,0 +1,217 @@
1
+ import { sql } from 'kysely';
2
+ import { parse } from 'pg-connection-string';
3
+ import { isLocalHost, MigrationFailed, Refused } from 'ultimabase';
4
+ import { id } from './plan-parts.js';
5
+ import { parseSnapshot } from './snapshot.js';
6
+ /** The tables UltimaBase keeps its own records in. */
7
+ export const tables = [
8
+ 'ultimabase_schema',
9
+ 'ultimabase_migrations',
10
+ ];
11
+ const bookkeeping = [
12
+ sql `create table if not exists ultimabase_schema (id integer primary key check (id = 1), snapshot jsonb not null, written_by text not null, written_at timestamptz not null)`,
13
+ sql `create table if not exists ultimabase_migrations (name text primary key, checksum text not null, schema_hash text, applied_at timestamptz not null)`,
14
+ ];
15
+ /**
16
+ * How long a statement waits for a table another session holds: past it,
17
+ * the statement fails rather than queue every query on that table behind it.
18
+ */
19
+ const lockTimeout = '5s';
20
+ /**
21
+ * Runs `run` in a transaction that holds the schema's lock, which it waits
22
+ * for as long as another sync or deploy holds it.
23
+ */
24
+ async function locked(db, run) {
25
+ await db.transaction().execute(async (trx) => {
26
+ await sql `select pg_advisory_xact_lock(hashtext(current_schema()))`.execute(trx);
27
+ // A file may set its own, with `set local lock_timeout`.
28
+ await sql.raw(`set local lock_timeout = '${lockTimeout}'`).execute(trx);
29
+ for (const statement of bookkeeping)
30
+ await statement.execute(trx);
31
+ await run(trx);
32
+ });
33
+ }
34
+ async function store(trx, to, by) {
35
+ await sql `insert into ultimabase_schema (id, snapshot, written_by, written_at)
36
+ values (1, ${JSON.stringify(to)}::jsonb, ${by}, now())
37
+ on conflict (id) do update set snapshot = excluded.snapshot,
38
+ written_by = excluded.written_by, written_at = excluded.written_at`.execute(trx);
39
+ }
40
+ const isApplied = (value) => Array.isArray(value) &&
41
+ value.every((item) => typeof item === 'object' &&
42
+ item !== null &&
43
+ 'name' in item &&
44
+ typeof item.name === 'string' &&
45
+ 'checksum' in item &&
46
+ typeof item.checksum === 'string' &&
47
+ (!('schema' in item) || typeof item.schema === 'string'));
48
+ /**
49
+ * The stored snapshot and the applied files, in one query. A deploy that
50
+ * stopped before it stored its snapshot leaves files and no snapshot.
51
+ */
52
+ export async function stored(db) {
53
+ try {
54
+ const { rows } = await sql `select
55
+ (select snapshot from ultimabase_schema) as snapshot,
56
+ (select written_by from ultimabase_schema) as written_by,
57
+ (select coalesce(json_agg(json_strip_nulls(json_build_object('name', name, 'checksum', checksum, 'schema', schema_hash)) order by name), '[]')
58
+ from ultimabase_migrations) as applied`.execute(db);
59
+ const [row] = rows;
60
+ const { snapshot, written_by: by, applied } = row ?? {};
61
+ if (!isApplied(applied))
62
+ throw new Error('ultimabase_migrations is broken.');
63
+ if (snapshot === null && applied.length === 0)
64
+ return undefined;
65
+ return {
66
+ snapshot: snapshot ?? undefined,
67
+ by: by === 'sync' ? 'sync' : 'deploy',
68
+ applied,
69
+ };
70
+ }
71
+ catch (error) {
72
+ if (error instanceof Error && 'code' in error && error.code === '42P01') {
73
+ return undefined;
74
+ }
75
+ throw error;
76
+ }
77
+ }
78
+ /** Refuses tables that no snapshot describes (D-105). */
79
+ async function refuseTables(trx, to) {
80
+ const names = Object.keys(parseSnapshot(to).tables);
81
+ const { rows } = await sql `
82
+ select table_name from information_schema.tables
83
+ where table_schema = current_schema() and table_name = any(${names})
84
+ order by table_name`.execute(trx);
85
+ if (rows.length === 0)
86
+ return;
87
+ const list = rows.map((row) => row.table_name).join(', ');
88
+ throw new Refused(`The database already holds ${list}, and no UltimaBase snapshot says what they are: sync only builds an empty database.`);
89
+ }
90
+ /** What to do when the database refuses with `code`, after its reason. */
91
+ function hint(code, transaction) {
92
+ if (code === '55P03') {
93
+ return ': another query held a table it changes past lock_timeout';
94
+ }
95
+ if (code !== '25001')
96
+ return '';
97
+ return transaction
98
+ ? ': put it in its own file, whose second line is `-- ultimabase no transaction`'
99
+ : ': a file without a transaction holds one statement: put each in its own file';
100
+ }
101
+ /** Runs `text`, and names `at` when the database refuses it. */
102
+ async function executed(db, text, at, transaction = true) {
103
+ try {
104
+ await sql.raw(text).execute(db);
105
+ }
106
+ catch (error) {
107
+ const reason = error instanceof Error ? error.message : String(error);
108
+ const code = error instanceof Error ? Reflect.get(error, 'code') : undefined;
109
+ throw new MigrationFailed(at, `${reason}${hint(code, transaction)}`, code === '55P03');
110
+ }
111
+ }
112
+ async function sync(db, run) {
113
+ await locked(db, async (trx) => {
114
+ const { rows } = await sql `
115
+ select snapshot = ${JSON.stringify(run.to)}::jsonb as done,
116
+ snapshot = ${run.from === undefined ? null : JSON.stringify(run.from)}::jsonb as same
117
+ from ultimabase_schema`.execute(trx);
118
+ const [row] = rows;
119
+ // Another process synced the same models first.
120
+ if (row?.done === true)
121
+ return;
122
+ if (row === undefined ? run.from !== undefined : row.same !== true) {
123
+ throw new Refused('The database changed while UltimaBase synced it: start again.');
124
+ }
125
+ if (row === undefined)
126
+ await refuseTables(trx, run.to);
127
+ for (const statement of run.statements) {
128
+ await executed(trx, statement.sql, statement.sql);
129
+ }
130
+ await store(trx, run.to, 'sync');
131
+ });
132
+ }
133
+ /** Whether another deploy applied `file` first. */
134
+ async function isDone(db, file) {
135
+ const { rows } = await sql `
136
+ select 1 from ultimabase_migrations where name = ${file.name}`.execute(db);
137
+ return rows.length > 0;
138
+ }
139
+ const done = (db, file) => sql `insert into ultimabase_migrations (name, checksum, schema_hash, applied_at)
140
+ values (${file.name}, ${file.checksum}, ${file.schema ?? null}, now())`.execute(db);
141
+ /**
142
+ * Runs a file outside a transaction, under the schema's lock taken for the
143
+ * session. It waits for locks as long as it needs: `create index
144
+ * concurrently` waits for running transactions without blocking any, and
145
+ * one that fails leaves an invalid index.
146
+ */
147
+ async function outside(db, file) {
148
+ await db.connection().execute(async (session) => {
149
+ await sql `select pg_advisory_lock(hashtext(current_schema()))`.execute(session);
150
+ try {
151
+ for (const statement of bookkeeping)
152
+ await statement.execute(session);
153
+ if (await isDone(session, file))
154
+ return;
155
+ await executed(session, file.sql, file.name, false);
156
+ await done(session, file);
157
+ }
158
+ finally {
159
+ await sql `select pg_advisory_unlock(hashtext(current_schema()))`.execute(session);
160
+ }
161
+ });
162
+ }
163
+ async function deploy(db, run) {
164
+ for (const file of run.files) {
165
+ if (!file.transaction) {
166
+ await outside(db, file);
167
+ continue;
168
+ }
169
+ await locked(db, async (trx) => {
170
+ if (await isDone(trx, file))
171
+ return;
172
+ await executed(trx, file.sql, file.name);
173
+ await done(trx, file);
174
+ });
175
+ }
176
+ await locked(db, async (trx) => store(trx, run.to, 'deploy'));
177
+ }
178
+ export async function apply(db, run) {
179
+ await (run.kind === 'sync' ? sync(db, run) : deploy(db, run));
180
+ }
181
+ /** The current schema's tables but an extension's, in name order. */
182
+ export async function tablesOf(db) {
183
+ const { rows } = await sql `
184
+ select relname as table_name from pg_class
185
+ where relnamespace = current_schema()::regnamespace
186
+ and relkind in ('r', 'p') and not relispartition
187
+ and not exists (select from pg_depend where classid = 'pg_class'::regclass
188
+ and objid = pg_class.oid and deptype = 'e')
189
+ order by relname`.execute(db);
190
+ return rows.map((row) => row.table_name);
191
+ }
192
+ /**
193
+ * Drops every table of the current schema but an extension's, such as
194
+ * PostGIS's `spatial_ref_sys`; returns the models' tables.
195
+ */
196
+ export async function reset(db) {
197
+ const dropped = [];
198
+ await locked(db, async (trx) => {
199
+ const names = await tablesOf(trx);
200
+ const drop = `drop table ${names.map(id).join(', ')} cascade`;
201
+ await executed(trx, drop, drop);
202
+ dropped.push(...names.filter((name) => !tables.includes(name)));
203
+ });
204
+ return dropped;
205
+ }
206
+ /**
207
+ * The host `url` connects to when it is not this machine, read as `pg`
208
+ * reads it: no host, or a socket's directory, is this machine.
209
+ */
210
+ export function remoteOf(url) {
211
+ const host = parse(url).host || process.env['PGHOST'] || '';
212
+ // pg reads an IPv6 host without the brackets a URL's hostname keeps.
213
+ const hostname = host.includes(':') ? `[${host}]` : host;
214
+ return host === '' || host.startsWith('/') || isLocalHost(hostname)
215
+ ? undefined
216
+ : host;
217
+ }
@@ -0,0 +1,66 @@
1
+ import type { ScalarType, Schema, SchemaField, SchemaModel } from 'ultimabase';
2
+ /**
3
+ * What a column holds: `id` and a `one` key hold uuids, a list the array of
4
+ * its elements' type (D-127), an integer field `integer` (D-132).
5
+ */
6
+ export type Kind = ScalarType | 'id' | 'integer' | 'text[]' | 'double precision[]' | 'integer[]';
7
+ export type Unique = {
8
+ readonly name: string;
9
+ readonly fields: readonly string[];
10
+ };
11
+ export type Table = {
12
+ readonly name: string;
13
+ readonly model: SchemaModel;
14
+ readonly fields: ReadonlyMap<string, SchemaField>;
15
+ /** The primary key, unique fields, unique indexes, one-to-one keys. */
16
+ readonly uniques: readonly Unique[];
17
+ };
18
+ /**
19
+ * A many-to-many side: the relation its join table is named after, the join
20
+ * table, the column of this side's ids and of the other's, and the tables
21
+ * those ids belong to.
22
+ */
23
+ export type Join = {
24
+ readonly relation: string;
25
+ readonly table: string;
26
+ readonly self: string;
27
+ readonly other: string;
28
+ readonly owner: string;
29
+ readonly target: string;
30
+ };
31
+ export declare function snake(name: string): string;
32
+ /** A name PostgreSQL keeps whole: a long one ends with its hash. */
33
+ export declare function constraint(name: string): string;
34
+ /** The relation field a foreign key belongs to, and the model it links. */
35
+ export type Relation = {
36
+ readonly model: string;
37
+ readonly field: string;
38
+ readonly target: string;
39
+ };
40
+ export declare class Catalog {
41
+ readonly tables: ReadonlyMap<string, Table>;
42
+ readonly joins: ReadonlyMap<string, Join>;
43
+ /** Constraint name → the model and fields a refusal reports. */
44
+ readonly uniques: ReadonlyMap<string, {
45
+ model: string;
46
+ fields: string[];
47
+ }>;
48
+ /** Foreign key name → the relation a refusal reports (D-99). */
49
+ readonly foreignKeys: ReadonlyMap<string, Relation>;
50
+ /** The text search configuration of `_search` (D-148). */
51
+ readonly language: string;
52
+ constructor(schema: Schema, language?: string);
53
+ table(model: string): Table;
54
+ /** The column of a system field, a scalar or a stored `one`. */
55
+ column(table: Table, name: string): string;
56
+ kind(table: Table, name: string): Kind;
57
+ join(model: string, field: string): Join;
58
+ /** Named as the snapshot names them: a `one` key, a join's column. */
59
+ private foreignKeysOf;
60
+ /**
61
+ * Both sides of each many-to-many, named after the side whose table sorts
62
+ * first, so a model renamed with its `dbName` keeps its join (D-98).
63
+ */
64
+ private joinsOf;
65
+ }
66
+ //# sourceMappingURL=names.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"names.d.ts","sourceRoot":"","sources":["../src/names.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,YAAY,CAAA;AAE9E;;;GAGG;AACH,MAAM,MAAM,IAAI,GACf,UAAU,GAAG,IAAI,GAAG,SAAS,GAAG,QAAQ,GAAG,oBAAoB,GAAG,WAAW,CAAA;AAE9E,MAAM,MAAM,MAAM,GAAG;IACpB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAA;CAClC,CAAA;AAED,MAAM,MAAM,KAAK,GAAG;IACnB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,KAAK,EAAE,WAAW,CAAA;IAC3B,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC,MAAM,EAAE,WAAW,CAAC,CAAA;IACjD,uEAAuE;IACvE,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAA;CACnC,CAAA;AAED;;;;GAIG;AACH,MAAM,MAAM,IAAI,GAAG;IAClB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;IACzB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CACvB,CAAA;AAQD,wBAAgB,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAK1C;AAED,oEAAoE;AACpE,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAM/C;AAED,2EAA2E;AAC3E,MAAM,MAAM,QAAQ,GAAG;IACtB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CACvB,CAAA;AAED,qBAAa,OAAO;IACnB,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC,MAAM,EAAE,KAAK,CAAC,CAAA;IAC3C,QAAQ,CAAC,KAAK,EAAE,WAAW,CAAC,MAAM,EAAE,IAAI,CAAC,CAAA;IACzC,gEAAgE;IAChE,QAAQ,CAAC,OAAO,EAAE,WAAW,CAAC,MAAM,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC,CAAA;IAC1E,gEAAgE;IAChE,QAAQ,CAAC,WAAW,EAAE,WAAW,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAA;IACnD,0DAA0D;IAC1D,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;IAEzB,YAAY,MAAM,EAAE,MAAM,EAAE,QAAQ,SAAW,EAoC9C;IAED,KAAK,CAAC,KAAK,EAAE,MAAM,GAAG,KAAK,CAI1B;IAED,gEAAgE;IAChE,MAAM,CAAC,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CASzC;IAED,IAAI,CAAC,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI,CAQrC;IAED,IAAI,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAIvC;IAED,sEAAsE;IACtE,OAAO,CAAC,aAAa;IAuBrB;;;OAGG;IACH,OAAO,CAAC,OAAO;CAqCf"}
package/dist/names.js ADDED
@@ -0,0 +1,175 @@
1
+ // Storage names (D-63, D-98): snake_case tables and columns that `dbName`
2
+ // overrides, the join tables of many-to-many relations, and constraint
3
+ // names that fit PostgreSQL's 63 bytes.
4
+ import { createHash } from 'node:crypto';
5
+ const system = new Map([
6
+ ['id', ['id', 'id']],
7
+ ['createdAt', ['created_at', 'dateTime']],
8
+ ['updatedAt', ['updated_at', 'dateTime']],
9
+ ]);
10
+ export function snake(name) {
11
+ return name
12
+ .replaceAll(/([a-z0-9])([A-Z])/g, '$1_$2')
13
+ .replaceAll(/([A-Z])([A-Z][a-z])/g, '$1_$2')
14
+ .toLowerCase();
15
+ }
16
+ /** A name PostgreSQL keeps whole: a long one ends with its hash. */
17
+ export function constraint(name) {
18
+ if (Buffer.byteLength(name) <= 63)
19
+ return name;
20
+ let head = name;
21
+ while (Buffer.byteLength(head) > 54)
22
+ head = head.slice(0, -1);
23
+ const hash = createHash('sha256').update(name).digest('hex').slice(0, 8);
24
+ return `${head}_${hash}`;
25
+ }
26
+ export class Catalog {
27
+ tables;
28
+ joins;
29
+ /** Constraint name → the model and fields a refusal reports. */
30
+ uniques;
31
+ /** Foreign key name → the relation a refusal reports (D-99). */
32
+ foreignKeys;
33
+ /** The text search configuration of `_search` (D-148). */
34
+ language;
35
+ constructor(schema, language = 'simple') {
36
+ this.language = language;
37
+ const tables = new Map();
38
+ for (const model of schema.models) {
39
+ const name = model.dbName ?? snake(model.name);
40
+ const fields = new Map(model.fields.map((field) => [field.name, field]));
41
+ tables.set(model.name, { name, model, fields, uniques: [] });
42
+ }
43
+ this.tables = tables;
44
+ const uniques = new Map();
45
+ for (const table of tables.values()) {
46
+ const sets = [['id']];
47
+ for (const field of table.model.fields) {
48
+ const stored = field.kind === 'scalar' || (field.kind === 'one' && field.stored);
49
+ if (stored && field.unique)
50
+ sets.push([field.name]);
51
+ }
52
+ for (const index of table.model.indexes) {
53
+ if (index.unique)
54
+ sets.push(index.fields);
55
+ }
56
+ const named = sets.map((fields, at) => ({
57
+ name: constraint(at === 0
58
+ ? `${table.name}_pkey`
59
+ : `${table.name}_${fields.map((field) => this.column(table, field)).join('_')}_key`),
60
+ fields,
61
+ }));
62
+ tables.set(table.model.name, { ...table, uniques: named });
63
+ for (const { name, fields } of named) {
64
+ uniques.set(name, { model: table.model.name, fields: [...fields] });
65
+ }
66
+ }
67
+ this.uniques = uniques;
68
+ this.joins = this.joinsOf();
69
+ this.foreignKeys = this.foreignKeysOf();
70
+ }
71
+ table(model) {
72
+ const table = this.tables.get(model);
73
+ if (table === undefined)
74
+ throw new Error(`Unknown model ${model}.`);
75
+ return table;
76
+ }
77
+ /** The column of a system field, a scalar or a stored `one`. */
78
+ column(table, name) {
79
+ const known = system.get(name);
80
+ if (known !== undefined)
81
+ return known[0];
82
+ const field = table.fields.get(name);
83
+ if (field?.kind === 'scalar')
84
+ return field.dbName ?? snake(name);
85
+ if (field?.kind === 'one' && field.stored) {
86
+ return field.dbName ?? `${snake(name)}_id`;
87
+ }
88
+ throw new Error(`${table.model.name}.${name} has no column.`);
89
+ }
90
+ kind(table, name) {
91
+ const known = system.get(name);
92
+ if (known !== undefined)
93
+ return known[1];
94
+ const field = table.fields.get(name);
95
+ if (field?.kind !== 'scalar')
96
+ return 'id';
97
+ if (field.integer === true)
98
+ return field.list ? 'integer[]' : 'integer';
99
+ if (!field.list)
100
+ return field.type;
101
+ return field.type === 'number' ? 'double precision[]' : 'text[]';
102
+ }
103
+ join(model, field) {
104
+ const join = this.joins.get(`${model}.${field}`);
105
+ if (join === undefined)
106
+ throw new Error(`${model}.${field} has no join.`);
107
+ return join;
108
+ }
109
+ /** Named as the snapshot names them: a `one` key, a join's column. */
110
+ foreignKeysOf() {
111
+ const keys = new Map();
112
+ for (const table of this.tables.values()) {
113
+ const model = table.model.name;
114
+ for (const field of table.model.fields) {
115
+ let key;
116
+ if (field.kind === 'one' && field.stored) {
117
+ key = `${table.name}_${this.column(table, field.name)}_fkey`;
118
+ }
119
+ else if (field.kind === 'many' &&
120
+ field.join &&
121
+ field.from === undefined) {
122
+ const join = this.join(model, field.name);
123
+ key = `${join.table}_${join.other}_fkey`;
124
+ }
125
+ else
126
+ continue;
127
+ const target = field.target;
128
+ keys.set(constraint(key), { model, field: field.name, target });
129
+ }
130
+ }
131
+ return keys;
132
+ }
133
+ /**
134
+ * Both sides of each many-to-many, named after the side whose table sorts
135
+ * first, so a model renamed with its `dbName` keeps its join (D-98).
136
+ */
137
+ joinsOf() {
138
+ const joins = new Map();
139
+ for (const table of this.tables.values()) {
140
+ for (const field of table.model.fields) {
141
+ // A view has no join of its own (D-151).
142
+ if (field.kind !== 'many' || !field.join || field.from !== undefined)
143
+ continue;
144
+ const target = this.table(field.target);
145
+ const self = table === target;
146
+ const first = self
147
+ ? field.name < field.inverse
148
+ : table.name < target.name;
149
+ if (!first)
150
+ continue;
151
+ const name = `${table.name}_${snake(field.name)}`;
152
+ const mine = self ? `${snake(field.inverse)}_id` : `${table.name}_id`;
153
+ const theirs = self ? `${snake(field.name)}_id` : `${target.name}_id`;
154
+ const relation = `${table.model.name}.${field.name}`;
155
+ joins.set(relation, {
156
+ relation,
157
+ table: name,
158
+ self: mine,
159
+ other: theirs,
160
+ owner: table.name,
161
+ target: target.name,
162
+ });
163
+ joins.set(`${target.model.name}.${field.inverse}`, {
164
+ relation,
165
+ table: name,
166
+ self: theirs,
167
+ other: mine,
168
+ owner: target.name,
169
+ target: table.name,
170
+ });
171
+ }
172
+ }
173
+ return joins;
174
+ }
175
+ }
@@ -0,0 +1,19 @@
1
+ import type { Changed } from 'ultimabase';
2
+ import type { Sql } from './filter.ts';
3
+ /** What the feed says of a part: its head, then an item per row. */
4
+ export type Noted = {
5
+ /** The JSON array's start: kind, model, then the field or fields. */
6
+ readonly head: string;
7
+ readonly item: Sql;
8
+ /** How many items a notification holds. */
9
+ readonly per: number;
10
+ };
11
+ /** A part of rows, told by their ids: 150 of 40 bytes fit beside a head. */
12
+ export declare function rows(kind: 'insert' | 'update' | 'delete', model: string, fields?: readonly string[]): Noted;
13
+ /** A part of a join table, told by its pairs, the model's id first. */
14
+ export declare function pairs(kind: 'link' | 'unlink', model: string, field: string, self: string, other: string): Noted;
15
+ /** The CTE that tells `channel` what each of `parts`, by its CTE, wrote. */
16
+ export declare function notifying(channel: string, parts: readonly (readonly [Sql, Noted])[]): Sql;
17
+ /** A notification's payload as the change it tells, if it is one. */
18
+ export declare function changedOf(payload: string | undefined): Changed | undefined;
19
+ //# sourceMappingURL=notify.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"notify.d.ts","sourceRoot":"","sources":["../src/notify.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,YAAY,CAAA;AAEzC,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,aAAa,CAAA;AAEtC,oEAAoE;AACpE,MAAM,MAAM,KAAK,GAAG;IACnB,qEAAqE;IACrE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,IAAI,EAAE,GAAG,CAAA;IAClB,2CAA2C;IAC3C,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;CACpB,CAAA;AAED,4EAA4E;AAC5E,wBAAgB,IAAI,CACnB,IAAI,EAAE,QAAQ,GAAG,QAAQ,GAAG,QAAQ,EACpC,KAAK,EAAE,MAAM,EACb,MAAM,CAAC,EAAE,SAAS,MAAM,EAAE,GACxB,KAAK,CAOP;AAED,uEAAuE;AACvE,wBAAgB,KAAK,CACpB,IAAI,EAAE,MAAM,GAAG,QAAQ,EACvB,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,GACX,KAAK,CAIP;AAED,4EAA4E;AAC5E,wBAAgB,SAAS,CACxB,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,SAAS,CAAC,SAAS,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC,EAAE,GACvC,GAAG,CAUL;AAWD,qEAAqE;AACrE,wBAAgB,SAAS,CAAC,OAAO,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,GAAG,SAAS,CA8B1E"}
package/dist/notify.js ADDED
@@ -0,0 +1,75 @@
1
+ // What a write changed, told by its own statements (D-154): each part's rows
2
+ // go to `pg_notify` by chunks that fit its 8000 bytes, or as `null` past
3
+ // `feedIds`. The notifications leave with the commit; a rollback drops them.
4
+ import { sql } from 'kysely';
5
+ import { feedFields, feedIds } from 'ultimabase';
6
+ /** A part of rows, told by their ids: 150 of 40 bytes fit beside a head. */
7
+ export function rows(kind, model, fields) {
8
+ const listed = fields === undefined || JSON.stringify(fields).length > feedFields
9
+ ? null
10
+ : fields;
11
+ const head = JSON.stringify([kind, model, listed]).slice(0, -1);
12
+ return { head: `${head},`, item: sql.id('id'), per: 150 };
13
+ }
14
+ /** A part of a join table, told by its pairs, the model's id first. */
15
+ export function pairs(kind, model, field, self, other) {
16
+ const head = JSON.stringify([kind, model, field]).slice(0, -1);
17
+ const item = sql `json_build_array(${sql.id(self)}, ${sql.id(other)})`;
18
+ return { head: `${head},`, item, per: 75 };
19
+ }
20
+ /** The CTE that tells `channel` what each of `parts`, by its CTE, wrote. */
21
+ export function notifying(channel, parts) {
22
+ const to = sql.lit(channel);
23
+ const most = sql.lit(feedIds);
24
+ const each = parts.map(([from, noted]) => {
25
+ const head = sql.lit(noted.head);
26
+ const count = sql `(select count(*) from ${from})`;
27
+ const chunked = sql `select ${noted.item} as "item", (row_number() over () - 1) / ${sql.lit(noted.per)} as "k" from ${from}`;
28
+ return sql `select pg_notify(${to}, ${head} || json_agg("s"."item")::text || ']') from (${chunked}) as "s" where ${count} <= ${most} group by "s"."k" union all select pg_notify(${to}, ${head} || 'null]') where ${count} > ${most}`;
29
+ });
30
+ return sql `"#feed" as materialized (${sql.join(each, sql ` union all `)})`;
31
+ }
32
+ const kinds = new Set(['insert', 'update', 'delete', 'link', 'unlink']);
33
+ const strings = (value) => Array.isArray(value) && value.every((item) => typeof item === 'string');
34
+ const pairList = (value) => Array.isArray(value) &&
35
+ value.every((item) => strings(item) && item.length === 2);
36
+ /** A notification's payload as the change it tells, if it is one. */
37
+ export function changedOf(payload) {
38
+ let parsed;
39
+ try {
40
+ parsed = JSON.parse(payload ?? '');
41
+ }
42
+ catch {
43
+ return undefined;
44
+ }
45
+ if (!Array.isArray(parsed) || parsed.length !== 4)
46
+ return undefined;
47
+ const kind = parsed[0];
48
+ const model = parsed[1];
49
+ const target = parsed[2];
50
+ const listed = parsed[3];
51
+ if (typeof kind !== 'string' || !kinds.has(kind))
52
+ return undefined;
53
+ if (typeof model !== 'string')
54
+ return undefined;
55
+ if (kind === 'link' || kind === 'unlink') {
56
+ if (typeof target !== 'string')
57
+ return undefined;
58
+ if (listed !== null && !pairList(listed))
59
+ return undefined;
60
+ return { kind, model, field: target, ...(listed ? { pairs: listed } : {}) };
61
+ }
62
+ if (kind !== 'insert' && kind !== 'update' && kind !== 'delete') {
63
+ return undefined;
64
+ }
65
+ if (target !== null && !strings(target))
66
+ return undefined;
67
+ if (listed !== null && !strings(listed))
68
+ return undefined;
69
+ return {
70
+ kind,
71
+ model,
72
+ ...(target ? { fields: target } : {}),
73
+ ...(listed ? { ids: listed } : {}),
74
+ };
75
+ }
@@ -0,0 +1,13 @@
1
+ import type { Arithmetic, ListUpdate, Write } from 'ultimabase';
2
+ import type { Sql } from './filter.ts';
3
+ import type { Catalog, Table } from './names.ts';
4
+ /** An update's computed assignments, `null` counting as 0. */
5
+ export declare function computed(catalog: Catalog, table: Table, compute?: {
6
+ readonly [field: string]: Arithmetic | ListUpdate;
7
+ }): Sql[];
8
+ /**
9
+ * PostgreSQL's numeric overflow, 22003, names no column: the error names
10
+ * the write's first computed number.
11
+ */
12
+ export declare function overflowed(given: Write, error: unknown): unknown;
13
+ //# sourceMappingURL=numbers.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"numbers.d.ts","sourceRoot":"","sources":["../src/numbers.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAE,UAAU,EAAE,KAAK,EAAE,MAAM,YAAY,CAAA;AAE/D,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,aAAa,CAAA;AAEtC,OAAO,KAAK,EAAE,OAAO,EAAQ,KAAK,EAAE,MAAM,YAAY,CAAA;AAwBtD,8DAA8D;AAC9D,wBAAgB,QAAQ,CACvB,OAAO,EAAE,OAAO,EAChB,KAAK,EAAE,KAAK,EACZ,OAAO,GAAE;IAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,GAAG,UAAU,GAAG,UAAU,CAAA;CAAO,GACjE,GAAG,EAAE,CAUP;AAED;;;GAGG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,OAAO,GAAG,OAAO,CAWhE"}
@@ -0,0 +1,49 @@
1
+ import { OutOfRange } from 'ultimabase';
2
+ import { value } from './filter.js';
3
+ import { sql } from 'kysely';
4
+ // An integer divided by an integer truncates toward zero, as D-132 says.
5
+ const operators = { increment: '+', decrement: '-', multiply: '*', divide: '/' };
6
+ /**
7
+ * A list update on `column`: `pushUnique` appends each element the list
8
+ * lacks once, in its first position's order; `remove` keeps the others in
9
+ * theirs.
10
+ */
11
+ function listed(column, { operation, elements }, kind) {
12
+ const given = value(kind, elements);
13
+ if (operation === 'push')
14
+ return sql `${column} || ${given}`;
15
+ if (operation === 'pushUnique') {
16
+ return sql `${column} || array(select e from unnest(${given}) with ordinality as o(e, i) where e <> all(${column}) group by e order by min(i))`;
17
+ }
18
+ return sql `array(select e from unnest(${column}) with ordinality as o(e, i) where e <> all(${given}) order by i)`;
19
+ }
20
+ /** An update's computed assignments, `null` counting as 0. */
21
+ export function computed(catalog, table, compute = {}) {
22
+ return Object.entries(compute).map(([field, operation]) => {
23
+ const column = sql.id(catalog.column(table, field));
24
+ const kind = catalog.kind(table, field);
25
+ if ('elements' in operation) {
26
+ return sql `${column} = ${listed(column, operation, kind)}`;
27
+ }
28
+ const operand = value(kind, operation.by);
29
+ return sql `${column} = coalesce(${column}, 0) ${sql.raw(operators[operation.operation])} ${operand}`;
30
+ });
31
+ }
32
+ /**
33
+ * PostgreSQL's numeric overflow, 22003, names no column: the error names
34
+ * the write's first computed number.
35
+ */
36
+ export function overflowed(given, error) {
37
+ const code = error instanceof Error && 'code' in error ? error.code : undefined;
38
+ if (code !== '22003')
39
+ return error;
40
+ for (const change of given.changes) {
41
+ if (change.kind !== 'update')
42
+ continue;
43
+ const computes = Object.entries(change.compute ?? {});
44
+ const [field] = computes.find(([, operation]) => 'by' in operation) ?? [];
45
+ if (field !== undefined)
46
+ return new OutOfRange(change.model, field);
47
+ }
48
+ return error;
49
+ }
@@ -0,0 +1,36 @@
1
+ import type { Row } from 'ultimabase';
2
+ import type { Sql } from './filter.ts';
3
+ import type { Catalog } from './names.ts';
4
+ import type { Noted } from './notify.ts';
5
+ /** How many parameters a piece of SQL binds. */
6
+ export declare function sizeOf(piece: Sql): number;
7
+ /** A change as a CTE, and what deciding where it runs needs of it. */
8
+ export type Part = {
9
+ readonly build: (gate: Sql | undefined) => Sql;
10
+ readonly table: string;
11
+ /** The columns that identify a row: `id`, or a join table's two keys. */
12
+ readonly key: readonly string[];
13
+ readonly inserts: boolean;
14
+ readonly removes: boolean;
15
+ readonly adds: boolean;
16
+ readonly reads: ReadonlySet<string>;
17
+ /** A join table's change: the pairs it links or unlinks, `self:other`. */
18
+ readonly pairs?: ReadonlySet<string>;
19
+ /** The one row it writes, by id, when its RETURNING holds only that row. */
20
+ readonly row?: string;
21
+ /** What the feed says of its rows (D-154). */
22
+ readonly noted: Noted;
23
+ };
24
+ export declare const fieldsOf: (row: {
25
+ readonly [field: string]: unknown;
26
+ }) => string[];
27
+ /** Rows inserted into a table, which all set the fields of the first. */
28
+ export declare function inserted(catalog: Catalog, model: string, rows: readonly Row[]): Part;
29
+ /**
30
+ * The parts of one statement each: a part starts a new one when it writes a
31
+ * table the statement writes (inserts, and a join table's distinct pairs,
32
+ * aside) or reads one, since the parts of a statement see the database as it
33
+ * was, or when its parameters would not fit in the `room` left.
34
+ */
35
+ export declare function grouped(parts: readonly Part[], room: number): Part[][];
36
+ //# sourceMappingURL=parts.d.ts.map