@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.
- package/README.md +40 -1
- package/app-workspace.d.ts +29 -2
- package/app-workspace.js +99 -8
- package/blueprint-construction.d.ts +60 -0
- package/blueprint-construction.js +161 -0
- package/context.d.ts +5 -1
- package/context.js +30 -5
- package/db-probe.d.ts +25 -17
- package/db-probe.js +97 -37
- package/esm/app-workspace.d.ts +29 -2
- package/esm/app-workspace.js +96 -8
- package/esm/blueprint-construction.d.ts +60 -0
- package/esm/blueprint-construction.js +154 -0
- package/esm/context.d.ts +5 -1
- package/esm/context.js +31 -6
- package/esm/db-probe.d.ts +25 -17
- package/esm/db-probe.js +96 -37
- package/esm/graphql-http.d.ts +35 -0
- package/esm/graphql-http.js +134 -0
- package/esm/host.d.ts +46 -0
- package/esm/host.js +9 -0
- package/esm/index.d.ts +11 -2
- package/esm/index.js +6 -0
- package/esm/provision-database/env-file.d.ts +23 -2
- package/esm/provision-database/env-file.js +86 -27
- package/esm/provision-database/pending-ticket.d.ts +33 -0
- package/esm/provision-database/pending-ticket.js +63 -0
- package/esm/provision-database/provision.d.ts +71 -0
- package/esm/provision-database/provision.js +557 -0
- package/esm/provision-database/request-database.d.ts +55 -3
- package/esm/provision-database/request-database.js +118 -17
- package/esm/schema-model/normalize.d.ts +21 -0
- package/esm/schema-model/normalize.js +244 -0
- package/esm/schema-model.d.ts +110 -0
- package/esm/schema-model.js +303 -0
- package/esm/tools/add-policies.d.ts +1 -1
- package/esm/tools/add-records.d.ts +1 -0
- package/esm/tools/add-records.js +18 -6
- package/esm/tools/add-relation.js +2 -3
- package/esm/tools/create-api-key.d.ts +2 -0
- package/esm/tools/create-api-key.js +27 -12
- package/esm/tools/manage-entity-types.d.ts +1 -1
- package/esm/tools/provision-blueprint.d.ts +6 -0
- package/esm/tools/provision-blueprint.js +11 -4
- package/esm/tools/provision-database.d.ts +8 -11
- package/esm/tools/provision-database.js +23 -243
- package/esm/tools/run-codegen.d.ts +19 -2
- package/esm/tools/run-codegen.js +184 -97
- package/esm/tools/templates.js +3 -15
- package/graphql-http.d.ts +35 -0
- package/graphql-http.js +140 -0
- package/host.d.ts +46 -0
- package/host.js +10 -0
- package/index.d.ts +11 -2
- package/index.js +26 -1
- package/package.json +4 -4
- package/provision-database/env-file.d.ts +23 -2
- package/provision-database/env-file.js +89 -28
- package/provision-database/pending-ticket.d.ts +33 -0
- package/provision-database/pending-ticket.js +72 -0
- package/provision-database/provision.d.ts +71 -0
- package/provision-database/provision.js +565 -0
- package/provision-database/request-database.d.ts +55 -3
- package/provision-database/request-database.js +124 -18
- package/schema-model/normalize.d.ts +21 -0
- package/schema-model/normalize.js +250 -0
- package/schema-model.d.ts +110 -0
- package/schema-model.js +309 -0
- package/tools/add-policies.d.ts +1 -1
- package/tools/add-records.d.ts +1 -0
- package/tools/add-records.js +18 -6
- package/tools/add-relation.js +2 -3
- package/tools/create-api-key.d.ts +2 -0
- package/tools/create-api-key.js +27 -12
- package/tools/manage-entity-types.d.ts +1 -1
- package/tools/provision-blueprint.d.ts +6 -0
- package/tools/provision-blueprint.js +11 -4
- package/tools/provision-database.d.ts +8 -11
- package/tools/provision-database.js +23 -245
- package/tools/run-codegen.d.ts +19 -2
- package/tools/run-codegen.js +184 -96
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|
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
|
|
111
|
-
// few times rather than failing a provision that is
|
|
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 (
|
|
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
|
|
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>;
|