@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.
- 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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
|
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
|
|
115
|
-
// few times rather than failing a provision that is
|
|
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 (
|
|
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
|
|
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>;
|