@agentic-kit/db-tools 0.11.5 → 0.12.1

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 (82) hide show
  1. package/README.md +40 -1
  2. package/app-workspace.d.ts +29 -2
  3. package/app-workspace.js +99 -8
  4. package/blueprint-construction.d.ts +60 -0
  5. package/blueprint-construction.js +161 -0
  6. package/context.d.ts +5 -1
  7. package/context.js +30 -5
  8. package/db-probe.d.ts +25 -17
  9. package/db-probe.js +97 -37
  10. package/esm/app-workspace.d.ts +29 -2
  11. package/esm/app-workspace.js +96 -8
  12. package/esm/blueprint-construction.d.ts +60 -0
  13. package/esm/blueprint-construction.js +154 -0
  14. package/esm/context.d.ts +5 -1
  15. package/esm/context.js +31 -6
  16. package/esm/db-probe.d.ts +25 -17
  17. package/esm/db-probe.js +96 -37
  18. package/esm/graphql-http.d.ts +35 -0
  19. package/esm/graphql-http.js +134 -0
  20. package/esm/host.d.ts +46 -0
  21. package/esm/host.js +9 -0
  22. package/esm/index.d.ts +11 -2
  23. package/esm/index.js +6 -0
  24. package/esm/provision-database/env-file.d.ts +23 -2
  25. package/esm/provision-database/env-file.js +86 -27
  26. package/esm/provision-database/pending-ticket.d.ts +33 -0
  27. package/esm/provision-database/pending-ticket.js +63 -0
  28. package/esm/provision-database/provision.d.ts +71 -0
  29. package/esm/provision-database/provision.js +557 -0
  30. package/esm/provision-database/request-database.d.ts +55 -3
  31. package/esm/provision-database/request-database.js +118 -17
  32. package/esm/schema-model/normalize.d.ts +21 -0
  33. package/esm/schema-model/normalize.js +244 -0
  34. package/esm/schema-model.d.ts +110 -0
  35. package/esm/schema-model.js +303 -0
  36. package/esm/tools/add-policies.d.ts +1 -1
  37. package/esm/tools/add-records.d.ts +1 -0
  38. package/esm/tools/add-records.js +18 -6
  39. package/esm/tools/add-relation.js +2 -3
  40. package/esm/tools/create-api-key.d.ts +2 -0
  41. package/esm/tools/create-api-key.js +27 -12
  42. package/esm/tools/manage-entity-types.d.ts +1 -1
  43. package/esm/tools/provision-blueprint.d.ts +6 -0
  44. package/esm/tools/provision-blueprint.js +11 -4
  45. package/esm/tools/provision-database.d.ts +8 -11
  46. package/esm/tools/provision-database.js +23 -243
  47. package/esm/tools/run-codegen.d.ts +19 -2
  48. package/esm/tools/run-codegen.js +184 -97
  49. package/esm/tools/templates.js +3 -15
  50. package/graphql-http.d.ts +35 -0
  51. package/graphql-http.js +140 -0
  52. package/host.d.ts +46 -0
  53. package/host.js +10 -0
  54. package/index.d.ts +11 -2
  55. package/index.js +26 -1
  56. package/package.json +4 -4
  57. package/provision-database/env-file.d.ts +23 -2
  58. package/provision-database/env-file.js +89 -28
  59. package/provision-database/pending-ticket.d.ts +33 -0
  60. package/provision-database/pending-ticket.js +72 -0
  61. package/provision-database/provision.d.ts +71 -0
  62. package/provision-database/provision.js +565 -0
  63. package/provision-database/request-database.d.ts +55 -3
  64. package/provision-database/request-database.js +124 -18
  65. package/schema-model/normalize.d.ts +21 -0
  66. package/schema-model/normalize.js +250 -0
  67. package/schema-model.d.ts +110 -0
  68. package/schema-model.js +309 -0
  69. package/tools/add-policies.d.ts +1 -1
  70. package/tools/add-records.d.ts +1 -0
  71. package/tools/add-records.js +18 -6
  72. package/tools/add-relation.js +2 -3
  73. package/tools/create-api-key.d.ts +2 -0
  74. package/tools/create-api-key.js +27 -12
  75. package/tools/manage-entity-types.d.ts +1 -1
  76. package/tools/provision-blueprint.d.ts +6 -0
  77. package/tools/provision-blueprint.js +11 -4
  78. package/tools/provision-database.d.ts +8 -11
  79. package/tools/provision-database.js +23 -245
  80. package/tools/run-codegen.d.ts +19 -2
  81. package/tools/run-codegen.js +184 -96
  82. package/tools/templates.js +3 -15
@@ -21,6 +21,33 @@ export const POLL_DEADLINE_MS = 240_000;
21
21
  * a creation that is about to succeed.
22
22
  */
23
23
  export const MAX_CONSECUTIVE_POLL_FAILURES = 3;
24
+ /**
25
+ * The provision is still running server-side, but this call stopped waiting:
26
+ * the deadline passed, or the backend couldn't be reached. A caller should keep
27
+ * `ticketId` and pass it back as `resumeTicketId`, because requesting the same
28
+ * name again starts a second provision rather than resuming this one.
29
+ */
30
+ export class ProvisionPendingError extends Error {
31
+ ticketId;
32
+ constructor(message, ticketId, options) {
33
+ super(message, options);
34
+ this.ticketId = ticketId;
35
+ this.name = 'ProvisionPendingError';
36
+ }
37
+ }
38
+ /**
39
+ * The recorded ticket can't be read back: it is gone, or this account can't
40
+ * see it. Nothing says the provision stopped, so a caller must not request
41
+ * the same database again on this alone.
42
+ */
43
+ export class ProvisionTicketLostError extends Error {
44
+ ticketId;
45
+ constructor(message, ticketId) {
46
+ super(message);
47
+ this.ticketId = ticketId;
48
+ this.name = 'ProvisionTicketLostError';
49
+ }
50
+ }
24
51
  const TICKET_SELECT = {
25
52
  id: true,
26
53
  status: true,
@@ -29,7 +56,9 @@ const TICKET_SELECT = {
29
56
  errorMessage: true,
30
57
  bootstrapError: true,
31
58
  };
32
- function createSdkExecutor(args) {
59
+ /** How many of an account's tickets for one name a lookup reads. */
60
+ const LIST_OWN_LIMIT = 5;
61
+ export function createProvisionExecutor(args) {
33
62
  const headers = { Authorization: `Bearer ${args.bearer}` };
34
63
  const apiClient = api.createClient({ adapter: new api.FetchAdapter(args.apiEndpoint, headers) });
35
64
  const modulesClient = modules.createClient({
@@ -59,8 +88,44 @@ function createSdkExecutor(args) {
59
88
  .unwrap();
60
89
  return result.databaseProvisionModule ?? null;
61
90
  },
91
+ async listOwn({ databaseName, ownerId }) {
92
+ const result = await modulesClient.databaseProvisionModule
93
+ .findMany({
94
+ select: { ...TICKET_SELECT, databaseName: true, createdAt: true },
95
+ where: { databaseName: { equalTo: databaseName }, ownerId: { equalTo: ownerId } },
96
+ orderBy: ['CREATED_AT_DESC'],
97
+ first: LIST_OWN_LIMIT,
98
+ })
99
+ .unwrap();
100
+ return (result.databaseProvisionModules?.nodes ?? []).filter((ticket) => Boolean(ticket));
101
+ },
62
102
  };
63
103
  }
104
+ /** A ticket whose provision or owner bootstrap is still running. */
105
+ export function isTicketInFlight(ticket) {
106
+ if (ticket.status === 'pending' || ticket.status === 'in_progress')
107
+ return true;
108
+ return ticket.status === 'completed' && (ticket.bootstrapStatus === 'pending' || ticket.bootstrapStatus === 'in_progress');
109
+ }
110
+ const NETWORK_ERROR_RE = /fetch failed|network|socket hang up|ECONNRESET|ECONNREFUSED|ETIMEDOUT|EAI_AGAIN|ENOTFOUND|aborted|timed? ?out/i;
111
+ /**
112
+ * Whether an error reading or requesting a ticket says only that the backend
113
+ * couldn't be reached or failed: a network error thrown before any response,
114
+ * or an HTTP 5xx/429 the adapter reports as the only error. A GraphQL error
115
+ * (validation, permission, an expired or rejected token) is an answer, so the
116
+ * ticket can't be followed any further.
117
+ */
118
+ export function isTransientProvisionError(err) {
119
+ const errors = err?.errors;
120
+ if (Array.isArray(errors)) {
121
+ return errors.length > 0 && errors.every((e) => /^HTTP (5\d\d|429)\b/.test(e?.message ?? ''));
122
+ }
123
+ if (!(err instanceof Error))
124
+ return false;
125
+ const code = err.cause?.code;
126
+ return NETWORK_ERROR_RE.test(`${err.message} ${typeof code === 'string' ? code : ''}`);
127
+ }
128
+ const errorText = (err) => (err instanceof Error ? err.message : String(err));
64
129
  function sleep(ms) {
65
130
  return new Promise((resolve) => setTimeout(resolve, ms));
66
131
  }
@@ -69,26 +134,54 @@ function sleep(ms) {
69
134
  * `status` AND `bootstrapStatus` both 'completed'. The ticket the mutation
70
135
  * returns is checked before the first sleep, so a warm claim with bootstrap
71
136
  * already done never waits. `status: 'failed'` surfaces `errorMessage`;
72
- * `bootstrapStatus: 'failed'` surfaces `bootstrapError`.
137
+ * `bootstrapStatus: 'failed'` surfaces `bootstrapError`. Stopping short of
138
+ * either outcome throws `ProvisionPendingError`.
139
+ *
140
+ * With `resumeTicketId` the ticket of an earlier call is polled instead of
141
+ * requesting again, and `resumed` is true. A ticket that reads back empty
142
+ * throws `ProvisionTicketLostError`, never a new request: the provision may
143
+ * still be running where this account can't see it.
73
144
  */
74
145
  export async function requestDatabaseProvision(args) {
75
146
  const executor = args.executor ??
76
- createSdkExecutor({
147
+ createProvisionExecutor({
77
148
  apiEndpoint: args.apiEndpoint,
78
149
  modulesEndpoint: args.modulesEndpoint,
79
150
  bearer: args.bearer,
80
151
  });
81
152
  const intervalMs = args.pollIntervalMs ?? POLL_INTERVAL_MS;
82
153
  const deadlineMs = args.pollDeadlineMs ?? POLL_DEADLINE_MS;
83
- let ticket = await executor.request({
84
- databaseName: args.databaseName,
85
- subdomain: args.databaseName,
86
- domain: args.domain,
87
- request: args.request,
88
- });
89
- const ticketId = ticket.id;
154
+ // A backend that can't be reached leaves the old ticket pending rather than
155
+ // falling back to a new request, which would start a duplicate provision.
156
+ // Any other error is final for that ticket.
157
+ let resumedTicket = null;
158
+ if (args.resumeTicketId) {
159
+ try {
160
+ resumedTicket = await executor.poll(args.resumeTicketId);
161
+ }
162
+ catch (err) {
163
+ if (isTransientProvisionError(err)) {
164
+ throw new ProvisionPendingError(errorText(err), args.resumeTicketId, { cause: err });
165
+ }
166
+ throw new Error(`provision ticket ${args.resumeTicketId} could not be read: ${errorText(err)}`, {
167
+ cause: err,
168
+ });
169
+ }
170
+ if (!resumedTicket) {
171
+ throw new ProvisionTicketLostError(`provision ticket ${args.resumeTicketId} could not be found on this backend.`, args.resumeTicketId);
172
+ }
173
+ }
174
+ let ticket = resumedTicket ??
175
+ (await executor.request({
176
+ databaseName: args.databaseName,
177
+ subdomain: args.databaseName,
178
+ domain: args.domain,
179
+ request: args.request,
180
+ }));
181
+ const ticketId = resumedTicket ? (ticket.id ?? args.resumeTicketId) : ticket.id;
90
182
  if (!ticketId)
91
183
  throw new Error('requestDatabase returned a provision ticket without an id.');
184
+ const resumed = resumedTicket ? { resumed: true } : {};
92
185
  const deadline = Date.now() + deadlineMs;
93
186
  let failures = 0;
94
187
  for (;;) {
@@ -101,26 +194,34 @@ export async function requestDatabaseProvision(args) {
101
194
  if (ticket.status === 'completed' && ticket.bootstrapStatus === 'completed') {
102
195
  if (!ticket.databaseId)
103
196
  throw new Error('provisioning completed but returned no databaseId.');
104
- return { databaseId: ticket.databaseId };
197
+ return { databaseId: ticket.databaseId, ticketId, ...resumed };
105
198
  }
106
199
  if (Date.now() >= deadline) {
107
- throw new Error(`provisioning timed out after ${Math.round(deadlineMs / 1000)}s (status: ${ticket.status ?? 'unknown'}, bootstrapStatus: ${ticket.bootstrapStatus ?? 'unknown'})`);
200
+ throw new ProvisionPendingError(`provisioning timed out after ${Math.round(deadlineMs / 1000)}s (status: ${ticket.status ?? 'unknown'}, bootstrapStatus: ${ticket.bootstrapStatus ?? 'unknown'})`, ticketId);
108
201
  }
109
202
  await sleep(intervalMs);
110
- // A poll that throws (network blip, 502) or comes back empty is retried a
111
- // few times rather than failing a provision that is still running.
203
+ // A poll that can't reach the backend (network blip, 502) or comes back
204
+ // empty is retried a few times rather than failing a provision that is
205
+ // still running. Past that, an unreachable backend leaves the ticket
206
+ // pending; an empty answer, or any error that is an answer, is final.
112
207
  let polled;
113
208
  try {
114
209
  polled = await executor.poll(ticketId);
115
210
  }
116
211
  catch (err) {
117
- if (++failures > MAX_CONSECUTIVE_POLL_FAILURES)
118
- throw err;
212
+ if (!isTransientProvisionError(err)) {
213
+ throw new Error(`provision ticket ${ticketId} could not be read: ${errorText(err)}`, {
214
+ cause: err,
215
+ });
216
+ }
217
+ if (++failures > MAX_CONSECUTIVE_POLL_FAILURES) {
218
+ throw new ProvisionPendingError(errorText(err), ticketId, { cause: err });
219
+ }
119
220
  continue;
120
221
  }
121
222
  if (!polled) {
122
223
  if (++failures > MAX_CONSECUTIVE_POLL_FAILURES) {
123
- throw new Error(`provision ticket ${ticketId} could not be read back.`);
224
+ throw new ProvisionTicketLostError(`provision ticket ${ticketId} could not be read back.`, ticketId);
124
225
  }
125
226
  continue;
126
227
  }
@@ -0,0 +1,21 @@
1
+ import type { ColumnDefault, FkAction } from '../schema-model';
2
+ /** A field's type as one name (`integer`, `timestamptz`, `email`, …) plus `[]` per array dimension. */
3
+ export declare function normalizeType(rawType: unknown): string;
4
+ /**
5
+ * A field's stored default as one of the shapes a planner can compare: a
6
+ * literal, the current time or date, a generated id, or an expression it
7
+ * should leave alone. `DEFAULT NULL` is no default.
8
+ */
9
+ export declare function columnDefault(raw: unknown): ColumnDefault | null;
10
+ export type CheckRead = {
11
+ values?: string[];
12
+ other: boolean;
13
+ };
14
+ /**
15
+ * What one check expression allows. An IN-list gives `values`; anything else
16
+ * it can't read (ranges, patterns, other columns) sets `other`. In an AND,
17
+ * one IN-list still gives its values, beside `other` for the rest.
18
+ */
19
+ export declare function readCheck(raw: unknown): CheckRead;
20
+ /** A stored action code (`c n r a d`) as a name; Postgres' default is no action. */
21
+ export declare function fkAction(code: unknown): FkAction;
@@ -0,0 +1,244 @@
1
+ // Pure readers for the metaschema's stored shapes: field types, structured
2
+ // defaults, check expressions (Postgres AST JSON) and foreign key actions.
3
+ import { fieldTypeToTypeName, toFieldDefault } from '@agentic-kit/harness';
4
+ const TYPE_ALIASES = {
5
+ int: 'integer',
6
+ int4: 'integer',
7
+ serial: 'integer',
8
+ serial4: 'integer',
9
+ int8: 'bigint',
10
+ bigserial: 'bigint',
11
+ serial8: 'bigint',
12
+ int2: 'smallint',
13
+ smallserial: 'smallint',
14
+ bool: 'boolean',
15
+ decimal: 'numeric',
16
+ float4: 'real',
17
+ float: 'double precision',
18
+ float8: 'double precision',
19
+ 'timestamp with time zone': 'timestamptz',
20
+ 'timestamp without time zone': 'timestamp',
21
+ 'time with time zone': 'timetz',
22
+ 'time without time zone': 'time',
23
+ 'character varying': 'varchar',
24
+ character: 'char',
25
+ bpchar: 'char',
26
+ };
27
+ /** A field's type as one name (`integer`, `timestamptz`, `email`, …) plus `[]` per array dimension. */
28
+ export function normalizeType(rawType) {
29
+ const name = fieldTypeToTypeName(rawType).trim().toLowerCase();
30
+ const dims = /(\[\])*$/.exec(name)?.[0] ?? '';
31
+ const base = name.slice(0, name.length - dims.length).trim();
32
+ return (TYPE_ALIASES[base] ?? base) + dims;
33
+ }
34
+ const NOW_KEYWORDS = new Set(['CURRENT_TIMESTAMP', 'LOCALTIMESTAMP']);
35
+ const NOW_FUNCTIONS = new Set([
36
+ 'now',
37
+ 'current_timestamp',
38
+ 'statement_timestamp',
39
+ 'transaction_timestamp',
40
+ 'clock_timestamp',
41
+ ]);
42
+ /**
43
+ * A field's stored default as one of the shapes a planner can compare: a
44
+ * literal, the current time or date, a generated id, or an expression it
45
+ * should leave alone. `DEFAULT NULL` is no default.
46
+ */
47
+ export function columnDefault(raw) {
48
+ if (raw === null || raw === undefined)
49
+ return null;
50
+ if (typeof raw === 'number' || typeof raw === 'boolean')
51
+ return { kind: 'value', value: raw };
52
+ if (typeof raw === 'string') {
53
+ const parsed = toFieldDefault(raw);
54
+ return parsed ? fromStructured(parsed) : null;
55
+ }
56
+ if (typeof raw === 'object' && !Array.isArray(raw))
57
+ return fromStructured(raw);
58
+ return { kind: 'expression', text: JSON.stringify(raw) };
59
+ }
60
+ function fromStructured(def) {
61
+ if (typeof def.sql_keyword === 'string') {
62
+ const keyword = def.sql_keyword.toUpperCase();
63
+ if (NOW_KEYWORDS.has(keyword))
64
+ return { kind: 'now' };
65
+ if (keyword === 'CURRENT_DATE')
66
+ return { kind: 'today' };
67
+ return { kind: 'expression', text: keyword };
68
+ }
69
+ if (typeof def.function === 'string') {
70
+ const fn = def.function.toLowerCase();
71
+ const bare = !(Array.isArray(def.args) && def.args.length > 0) && def.cast == null;
72
+ if (bare && NOW_FUNCTIONS.has(fn))
73
+ return { kind: 'now' };
74
+ if (bare && fn === 'current_date')
75
+ return { kind: 'today' };
76
+ if (bare && fn.includes('uuid'))
77
+ return { kind: 'generated', fn: def.function };
78
+ return { kind: 'expression', text: renderDefault(def) };
79
+ }
80
+ if ('value' in def && def.operator === undefined) {
81
+ const value = def.value;
82
+ if (value === null || value === undefined)
83
+ return null;
84
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
85
+ return { kind: 'value', value };
86
+ }
87
+ return { kind: 'expression', text: JSON.stringify(value) };
88
+ }
89
+ return { kind: 'expression', text: renderDefault(def) };
90
+ }
91
+ const sqlLiteral = (value) => typeof value === 'string' ? `'${value.replace(/'/g, "''")}'` : JSON.stringify(value);
92
+ // SQL-ish text for a default no other shape fits; it is shown, never run.
93
+ function renderDefault(def) {
94
+ if (def === null || def === undefined)
95
+ return 'NULL';
96
+ if (typeof def !== 'object')
97
+ return sqlLiteral(def);
98
+ const d = def;
99
+ let text;
100
+ if (typeof d.sql_keyword === 'string')
101
+ text = d.sql_keyword;
102
+ else if (typeof d.function === 'string') {
103
+ text = `${d.function}(${Array.isArray(d.args) ? d.args.map(renderDefault).join(', ') : ''})`;
104
+ }
105
+ else if (typeof d.operator === 'string')
106
+ text = `${renderDefault(d.left)} ${d.operator} ${renderDefault(d.right)}`;
107
+ else if ('value' in d)
108
+ text = d.value === null ? 'NULL' : sqlLiteral(d.value);
109
+ else
110
+ return JSON.stringify(def);
111
+ const cast = fieldTypeToTypeName(d.cast);
112
+ return cast ? `${text}::${cast}` : text;
113
+ }
114
+ const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
115
+ /** A node's list: a plain array, or a `{ List: { items } }` wrapper. */
116
+ function listItems(value) {
117
+ if (Array.isArray(value))
118
+ return value;
119
+ if (isObject(value)) {
120
+ const list = isObject(value.List) ? value.List : value;
121
+ if (Array.isArray(list.items))
122
+ return list.items;
123
+ }
124
+ return undefined;
125
+ }
126
+ // `{ expression: <ast> }` is how the metaschema stores a raw AST on
127
+ // field.chk and check_constraint.expr.
128
+ function unwrapExpression(value) {
129
+ return isObject(value) && 'expression' in value ? value.expression : value;
130
+ }
131
+ function unwrapCast(value) {
132
+ let node = value;
133
+ while (isObject(node) && isObject(node.TypeCast))
134
+ node = node.TypeCast.arg;
135
+ return node;
136
+ }
137
+ const stringOf = (value) => {
138
+ if (typeof value === 'string')
139
+ return value;
140
+ if (isObject(value)) {
141
+ if (typeof value.sval === 'string')
142
+ return value.sval;
143
+ if (typeof value.str === 'string')
144
+ return value.str;
145
+ if (isObject(value.String))
146
+ return stringOf(value.String);
147
+ }
148
+ return undefined;
149
+ };
150
+ /** The operator of an `A_Expr`: the last name in its `name` list. */
151
+ function operatorOf(name) {
152
+ const items = listItems(name);
153
+ return items && items.length > 0 ? stringOf(items[items.length - 1]) : undefined;
154
+ }
155
+ /** An `A_Const` as text: `sval` plain or wrapped, `ival`, `fval`, `boolval`. */
156
+ function constValue(value) {
157
+ const node = unwrapCast(value);
158
+ if (!isObject(node) || !isObject(node.A_Const))
159
+ return undefined;
160
+ const c = node.A_Const;
161
+ if (c.isnull === true)
162
+ return undefined;
163
+ if ('sval' in c)
164
+ return stringOf(c.sval);
165
+ if ('ival' in c) {
166
+ // The parser's JSON leaves out zero: `{ ival: {} }` is 0.
167
+ const ival = isObject(c.ival) ? (c.ival.ival ?? 0) : c.ival;
168
+ return typeof ival === 'number' ? String(ival) : undefined;
169
+ }
170
+ if ('fval' in c) {
171
+ const fval = isObject(c.fval) ? c.fval.fval : c.fval;
172
+ return typeof fval === 'string' || typeof fval === 'number' ? String(fval) : undefined;
173
+ }
174
+ if ('boolval' in c) {
175
+ const boolval = isObject(c.boolval) ? (c.boolval.boolval ?? false) : c.boolval;
176
+ return typeof boolval === 'boolean' ? String(boolval) : undefined;
177
+ }
178
+ if (isObject(c.val)) {
179
+ const val = c.val;
180
+ if (isObject(val.String))
181
+ return stringOf(val.String);
182
+ if (isObject(val.Integer))
183
+ return String(val.Integer.ival ?? 0);
184
+ }
185
+ return undefined;
186
+ }
187
+ /** The values of `col IN (…)` or `col = ANY (ARRAY[…])`; undefined for anything else. */
188
+ function inListValues(value) {
189
+ if (!isObject(value) || !isObject(value.A_Expr))
190
+ return undefined;
191
+ const expr = value.A_Expr;
192
+ if (operatorOf(expr.name) !== '=')
193
+ return undefined;
194
+ let items;
195
+ if (expr.kind === 'AEXPR_IN') {
196
+ items = listItems(expr.rexpr);
197
+ }
198
+ else if (expr.kind === 'AEXPR_OP_ANY') {
199
+ const array = unwrapCast(expr.rexpr);
200
+ items = isObject(array) && isObject(array.A_ArrayExpr) ? listItems(array.A_ArrayExpr.elements) : undefined;
201
+ }
202
+ if (!items || items.length === 0)
203
+ return undefined;
204
+ const values = items.map(constValue);
205
+ return values.every((v) => v !== undefined) ? values : undefined;
206
+ }
207
+ /**
208
+ * What one check expression allows. An IN-list gives `values`; anything else
209
+ * it can't read (ranges, patterns, other columns) sets `other`. In an AND,
210
+ * one IN-list still gives its values, beside `other` for the rest.
211
+ */
212
+ export function readCheck(raw) {
213
+ const node = unwrapExpression(raw);
214
+ if (node === null || node === undefined)
215
+ return { other: false };
216
+ if (isObject(node) && isObject(node.BoolExpr) && /AND/i.test(String(node.BoolExpr.boolop ?? ''))) {
217
+ const parts = (listItems(node.BoolExpr.args) ?? []).map(readCheck);
218
+ const lists = parts.filter((part) => part.values);
219
+ return {
220
+ values: lists.length === 1 ? lists[0].values : undefined,
221
+ other: lists.length > 1 || parts.some((part) => part.other),
222
+ };
223
+ }
224
+ const values = inListValues(node);
225
+ return values ? { values, other: false } : { other: true };
226
+ }
227
+ // ---- foreign keys -----------------------------------------------------
228
+ const FK_ACTIONS = {
229
+ c: 'cascade',
230
+ n: 'set_null',
231
+ r: 'restrict',
232
+ a: 'no_action',
233
+ d: 'set_default',
234
+ cascade: 'cascade',
235
+ set_null: 'set_null',
236
+ restrict: 'restrict',
237
+ no_action: 'no_action',
238
+ set_default: 'set_default',
239
+ };
240
+ /** A stored action code (`c n r a d`) as a name; Postgres' default is no action. */
241
+ export function fkAction(code) {
242
+ const key = String(code ?? '').trim().toLowerCase().replace(/\s+/g, '_');
243
+ return FK_ACTIONS[key] ?? 'no_action';
244
+ }
@@ -0,0 +1,110 @@
1
+ import type { ProjectContext } from './context';
2
+ import { type GraphQLError } from './graphql-http';
3
+ export type ObjectCategory = 'APP' | 'AUTH' | 'CAPABILITIES' | 'CORE' | 'MEMBERSHIPS' | 'MODULE';
4
+ export type ColumnDefault = {
5
+ kind: 'value';
6
+ value: string | number | boolean | null;
7
+ } | {
8
+ kind: 'now';
9
+ } | {
10
+ kind: 'today';
11
+ }
12
+ /** A generated id: `uuidv7()`, `gen_random_uuid()`, … */
13
+ | {
14
+ kind: 'generated';
15
+ fn: string;
16
+ }
17
+ /** Anything else; a planner should leave the column's default alone. */
18
+ | {
19
+ kind: 'expression';
20
+ text: string;
21
+ };
22
+ export type ModelColumn = {
23
+ id: string;
24
+ name: string;
25
+ /** A status column made by DataStatusField is 'MODULE'. */
26
+ category: ObjectCategory;
27
+ order: number;
28
+ /** Normalised (`integer`, `timestamptz`, `email`, …) plus `[]` per array dimension. */
29
+ type: string;
30
+ rawType: Record<string, unknown> | null;
31
+ isRequired: boolean;
32
+ default: ColumnDefault | null;
33
+ /** The values an IN-list check allows. `_meta` stays the enum source; these spot disagreement. */
34
+ allowedValues?: string[];
35
+ /** True when some other check (range, pattern, …) applies too. */
36
+ hasOtherCheck?: boolean;
37
+ isPrimaryKey: boolean;
38
+ /** True when a single-column unique constraint covers it. */
39
+ isUnique: boolean;
40
+ /** An identity or generated column. */
41
+ generated: boolean;
42
+ label?: string;
43
+ description?: string;
44
+ smartTags?: Record<string, unknown>;
45
+ };
46
+ export type FkAction = 'cascade' | 'set_null' | 'restrict' | 'no_action' | 'set_default';
47
+ export type ModelForeignKey = {
48
+ id: string;
49
+ name?: string;
50
+ columnIds: string[];
51
+ columns: string[];
52
+ targetTableId: string;
53
+ targetTable: string;
54
+ targetCategory: ObjectCategory | null;
55
+ targetColumns: string[];
56
+ onDelete: FkAction;
57
+ onUpdate: FkAction;
58
+ };
59
+ export type ModelPolicy = {
60
+ id: string;
61
+ type: string;
62
+ privilege: string;
63
+ role: string;
64
+ permissive: boolean;
65
+ disabled: boolean;
66
+ data: Record<string, unknown> | null;
67
+ withCheck?: Record<string, unknown> | null;
68
+ };
69
+ export type TableAccess = 'shared' | 'private' | 'custom';
70
+ export type ModelTable = {
71
+ id: string;
72
+ name: string;
73
+ category: ObjectCategory;
74
+ label?: string;
75
+ columns: ModelColumn[];
76
+ foreignKeys: ModelForeignKey[];
77
+ uniqueConstraints: {
78
+ id: string;
79
+ columns: string[];
80
+ }[];
81
+ access: TableAccess;
82
+ /** The table's policies, internal ones left out. */
83
+ policies: ModelPolicy[];
84
+ };
85
+ export type SchemaModel = {
86
+ databaseId: string;
87
+ /** The schema hash, read before anything else; null on a backend without it. */
88
+ hash: string | null;
89
+ /** APP tables, sorted by name. */
90
+ tables: ModelTable[];
91
+ };
92
+ /** A read failed, so there is no model: a partial one would look like columns dropped outside. */
93
+ export declare class SchemaReadError extends Error {
94
+ readonly step: string;
95
+ readonly errors?: readonly GraphQLError[];
96
+ constructor(message: string, step: string, errors?: readonly GraphQLError[]);
97
+ }
98
+ /**
99
+ * The access mode a table's policies amount to. `private` is the app-member
100
+ * owner policy on `owner_id` and `shared` the plain app-membership policy,
101
+ * each for the authenticated role over select, insert, update and delete.
102
+ * Anything else, no policies included, is `custom`. Disabled and internal
103
+ * policies don't count.
104
+ */
105
+ export declare function accessOf(policies: readonly ModelPolicy[]): TableAccess;
106
+ /**
107
+ * Read the APP tables of the context's database. All or nothing: any failed
108
+ * query throws `SchemaReadError`.
109
+ */
110
+ export declare function readSchemaModel(context: ProjectContext): Promise<SchemaModel>;