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