@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
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
// The live schema of a project's app tables, read from the metaschema in one
|
|
2
|
+
// piece: every APP table with every field (whatever its category), keys,
|
|
3
|
+
// checks, foreign keys and policies. The ids are the metaschema's own, so they
|
|
4
|
+
// stay put across renames.
|
|
5
|
+
import { isInternalPolicy } from '@agentic-kit/harness';
|
|
6
|
+
import { errorsText } from './graphql-http';
|
|
7
|
+
import { columnDefault, fkAction, normalizeType, readCheck } from './schema-model/normalize';
|
|
8
|
+
/** A read failed, so there is no model: a partial one would look like columns dropped outside. */
|
|
9
|
+
export class SchemaReadError extends Error {
|
|
10
|
+
step;
|
|
11
|
+
errors;
|
|
12
|
+
constructor(message, step, errors) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.step = step;
|
|
15
|
+
this.errors = errors;
|
|
16
|
+
this.name = 'SchemaReadError';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
// ---- access ------------------------------------------------------------
|
|
20
|
+
const CRUD = ['select', 'insert', 'update', 'delete'];
|
|
21
|
+
const OWNER_FIELD = 'owner_id';
|
|
22
|
+
// Keys the backend fills in when it stores an app-membership policy: where the
|
|
23
|
+
// membership table lives, and that it's the app's (type 1, not an org's).
|
|
24
|
+
const RESOLVED_KEYS = new Set(['sprt_table', 'sprt_schema']);
|
|
25
|
+
const APP_MEMBERSHIP_TYPE = 1;
|
|
26
|
+
// A parameter counts only when it narrows the policy: `is_admin: false` or
|
|
27
|
+
// `levels: []` is the same as leaving it out.
|
|
28
|
+
const isSet = (value) => Array.isArray(value)
|
|
29
|
+
? value.length > 0
|
|
30
|
+
: typeof value === 'object' && value !== null
|
|
31
|
+
? Object.keys(value).length > 0
|
|
32
|
+
: Boolean(value);
|
|
33
|
+
/** Whether a policy parameter narrows who the policy lets in. */
|
|
34
|
+
const narrows = (data, key) => {
|
|
35
|
+
if (key === 'owner_field' || RESOLVED_KEYS.has(key))
|
|
36
|
+
return false;
|
|
37
|
+
if (key === 'membership_type')
|
|
38
|
+
return Number(data[key]) !== APP_MEMBERSHIP_TYPE;
|
|
39
|
+
return isSet(data[key]);
|
|
40
|
+
};
|
|
41
|
+
function modeOf(policy) {
|
|
42
|
+
if (!policy.permissive || policy.role !== 'authenticated')
|
|
43
|
+
return null;
|
|
44
|
+
if (policy.withCheck && isSet(policy.withCheck))
|
|
45
|
+
return null;
|
|
46
|
+
const data = policy.data ?? {};
|
|
47
|
+
if (Object.keys(data).some((key) => narrows(data, key)))
|
|
48
|
+
return null;
|
|
49
|
+
if (policy.type === 'AuthzAppMembership')
|
|
50
|
+
return isSet(data.owner_field) ? null : 'shared';
|
|
51
|
+
if (policy.type === 'AuthzAppMemberOwner') {
|
|
52
|
+
const owner = data.owner_field;
|
|
53
|
+
return owner === undefined || owner === null || owner === OWNER_FIELD ? 'private' : null;
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The access mode a table's policies amount to. `private` is the app-member
|
|
59
|
+
* owner policy on `owner_id` and `shared` the plain app-membership policy,
|
|
60
|
+
* each for the authenticated role over select, insert, update and delete.
|
|
61
|
+
* Anything else, no policies included, is `custom`. Disabled and internal
|
|
62
|
+
* policies don't count.
|
|
63
|
+
*/
|
|
64
|
+
export function accessOf(policies) {
|
|
65
|
+
const live = policies.filter((policy) => !policy.disabled && !isInternalPolicy(policy.type));
|
|
66
|
+
if (live.length === 0)
|
|
67
|
+
return 'custom';
|
|
68
|
+
const modes = new Set(live.map(modeOf));
|
|
69
|
+
if (modes.size !== 1 || modes.has(null))
|
|
70
|
+
return 'custom';
|
|
71
|
+
const covered = new Set(live.flatMap((policy) => {
|
|
72
|
+
const privilege = (policy.privilege ?? '').toLowerCase();
|
|
73
|
+
return privilege === 'all' ? CRUD : [privilege];
|
|
74
|
+
}));
|
|
75
|
+
return CRUD.every((privilege) => covered.has(privilege)) ? [...modes][0] : 'custom';
|
|
76
|
+
}
|
|
77
|
+
const nodesOf = (connection) => (connection?.nodes ?? []).filter((node) => Boolean(node));
|
|
78
|
+
async function read(step, query) {
|
|
79
|
+
let result;
|
|
80
|
+
try {
|
|
81
|
+
result = await query.execute();
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
throw new SchemaReadError(`Could not read ${step}: ${err instanceof Error ? err.message : String(err)}`, step);
|
|
85
|
+
}
|
|
86
|
+
if (!result.ok) {
|
|
87
|
+
throw new SchemaReadError(`Could not read ${step}: ${errorsText(result.errors ?? [])}`, step, result.errors);
|
|
88
|
+
}
|
|
89
|
+
return result.data;
|
|
90
|
+
}
|
|
91
|
+
const asRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value) ? value : null;
|
|
92
|
+
const groupBy = (rows) => {
|
|
93
|
+
const map = new Map();
|
|
94
|
+
for (const row of rows) {
|
|
95
|
+
const list = map.get(row.tableId) ?? [];
|
|
96
|
+
list.push(row);
|
|
97
|
+
map.set(row.tableId, list);
|
|
98
|
+
}
|
|
99
|
+
return map;
|
|
100
|
+
};
|
|
101
|
+
// The hash first: anything that changes the schema after this read moves it,
|
|
102
|
+
// so a caller comparing hashes later never trusts a model older than it looks.
|
|
103
|
+
async function readHash(context) {
|
|
104
|
+
try {
|
|
105
|
+
const data = await read('the database', context.api.database.findMany({
|
|
106
|
+
select: { id: true, hash: true },
|
|
107
|
+
where: { id: { equalTo: context.databaseId } },
|
|
108
|
+
first: 1,
|
|
109
|
+
}));
|
|
110
|
+
const node = nodesOf(data.databases)[0];
|
|
111
|
+
if (!node) {
|
|
112
|
+
throw new SchemaReadError(`Database ${context.databaseId} was not found on this backend.`, 'the database');
|
|
113
|
+
}
|
|
114
|
+
return node.hash ?? null;
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
// A backend from before Database.hash.
|
|
118
|
+
if (err instanceof SchemaReadError && (err.errors ?? []).some((e) => /\bhash\b/.test(e?.message ?? ''))) {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
throw err;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Read the APP tables of the context's database. All or nothing: any failed
|
|
126
|
+
* query throws `SchemaReadError`.
|
|
127
|
+
*/
|
|
128
|
+
export async function readSchemaModel(context) {
|
|
129
|
+
const { api, databaseId } = context;
|
|
130
|
+
const hash = await readHash(context);
|
|
131
|
+
// Every table of the database, so a foreign key to a non-APP table (users)
|
|
132
|
+
// still names its target.
|
|
133
|
+
const tableData = await read('tables', api.table.findMany({
|
|
134
|
+
select: { id: true, name: true, category: true, label: true },
|
|
135
|
+
where: { databaseId: { equalTo: databaseId } },
|
|
136
|
+
orderBy: ['NAME_ASC'],
|
|
137
|
+
}));
|
|
138
|
+
const allTables = nodesOf(tableData.tables);
|
|
139
|
+
const tablesById = new Map(allTables.map((table) => [table.id, table]));
|
|
140
|
+
const appTables = allTables
|
|
141
|
+
.filter((table) => table.category === 'APP')
|
|
142
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
143
|
+
if (appTables.length === 0)
|
|
144
|
+
return { databaseId, hash, tables: [] };
|
|
145
|
+
const inApp = { tableId: { in: appTables.map((table) => table.id) } };
|
|
146
|
+
const [fieldData, pkData, uniqueData, checkData, fkData, policyData] = await Promise.all([
|
|
147
|
+
read('fields', api.field.findMany({
|
|
148
|
+
select: {
|
|
149
|
+
id: true,
|
|
150
|
+
name: true,
|
|
151
|
+
tableId: true,
|
|
152
|
+
category: true,
|
|
153
|
+
fieldOrder: true,
|
|
154
|
+
type: true,
|
|
155
|
+
isRequired: true,
|
|
156
|
+
defaultValue: true,
|
|
157
|
+
chk: true,
|
|
158
|
+
chkExpr: true,
|
|
159
|
+
generationType: true,
|
|
160
|
+
identityGeneration: true,
|
|
161
|
+
label: true,
|
|
162
|
+
description: true,
|
|
163
|
+
smartTags: true,
|
|
164
|
+
},
|
|
165
|
+
where: inApp,
|
|
166
|
+
})),
|
|
167
|
+
read('primary keys', api.primaryKeyConstraint.findMany({
|
|
168
|
+
select: { id: true, tableId: true, fieldIds: true },
|
|
169
|
+
where: inApp,
|
|
170
|
+
})),
|
|
171
|
+
read('unique constraints', api.uniqueConstraint.findMany({
|
|
172
|
+
select: { id: true, tableId: true, fieldIds: true },
|
|
173
|
+
where: inApp,
|
|
174
|
+
})),
|
|
175
|
+
read('check constraints', api.checkConstraint.findMany({
|
|
176
|
+
select: { id: true, tableId: true, fieldIds: true, expr: true },
|
|
177
|
+
where: inApp,
|
|
178
|
+
})),
|
|
179
|
+
read('foreign keys', api.foreignKeyConstraint.findMany({
|
|
180
|
+
select: {
|
|
181
|
+
id: true,
|
|
182
|
+
name: true,
|
|
183
|
+
tableId: true,
|
|
184
|
+
fieldIds: true,
|
|
185
|
+
refTableId: true,
|
|
186
|
+
refFieldIds: true,
|
|
187
|
+
deleteAction: true,
|
|
188
|
+
updateAction: true,
|
|
189
|
+
},
|
|
190
|
+
where: inApp,
|
|
191
|
+
})),
|
|
192
|
+
read('policies', api.policy.findMany({
|
|
193
|
+
select: {
|
|
194
|
+
id: true,
|
|
195
|
+
tableId: true,
|
|
196
|
+
policyType: true,
|
|
197
|
+
privilege: true,
|
|
198
|
+
granteeName: true,
|
|
199
|
+
permissive: true,
|
|
200
|
+
disabled: true,
|
|
201
|
+
data: true,
|
|
202
|
+
withCheck: true,
|
|
203
|
+
},
|
|
204
|
+
where: inApp,
|
|
205
|
+
})),
|
|
206
|
+
]);
|
|
207
|
+
const fields = nodesOf(fieldData.fields);
|
|
208
|
+
const fieldNames = new Map(fields.map((field) => [field.id, field.name]));
|
|
209
|
+
const foreignKeys = nodesOf(fkData.foreignKeyConstraints);
|
|
210
|
+
// Columns a foreign key points at outside the APP tables (users.id).
|
|
211
|
+
const outside = [
|
|
212
|
+
...new Set(foreignKeys.flatMap((fk) => fk.refFieldIds ?? []).filter((id) => !fieldNames.has(id))),
|
|
213
|
+
];
|
|
214
|
+
if (outside.length > 0) {
|
|
215
|
+
const refData = await read('referenced fields', api.field.findMany({
|
|
216
|
+
select: { id: true, name: true },
|
|
217
|
+
where: { id: { in: outside } },
|
|
218
|
+
}));
|
|
219
|
+
for (const field of nodesOf(refData.fields))
|
|
220
|
+
fieldNames.set(field.id, field.name);
|
|
221
|
+
}
|
|
222
|
+
const fieldsByTable = groupBy(fields);
|
|
223
|
+
const pksByTable = groupBy(nodesOf(pkData.primaryKeyConstraints));
|
|
224
|
+
const uniquesByTable = groupBy(nodesOf(uniqueData.uniqueConstraints));
|
|
225
|
+
const checksByTable = groupBy(nodesOf(checkData.checkConstraints));
|
|
226
|
+
const fksByTable = groupBy(foreignKeys);
|
|
227
|
+
const policiesByTable = groupBy(nodesOf(policyData.policies));
|
|
228
|
+
const names = (ids) => (ids ?? []).map((id) => fieldNames.get(id) ?? id);
|
|
229
|
+
const tables = appTables.map((table) => {
|
|
230
|
+
const pkIds = new Set((pksByTable.get(table.id) ?? []).flatMap((pk) => pk.fieldIds ?? []));
|
|
231
|
+
const uniques = uniquesByTable.get(table.id) ?? [];
|
|
232
|
+
const uniqueIds = new Set(uniques.filter((u) => (u.fieldIds ?? []).length === 1).map((u) => (u.fieldIds ?? [])[0]));
|
|
233
|
+
const checks = checksByTable.get(table.id) ?? [];
|
|
234
|
+
const columns = (fieldsByTable.get(table.id) ?? [])
|
|
235
|
+
.map((field, index) => {
|
|
236
|
+
const chk = asRecord(field.chk);
|
|
237
|
+
const sources = [
|
|
238
|
+
chk && 'expression' in chk ? chk.expression : field.chk,
|
|
239
|
+
field.chkExpr,
|
|
240
|
+
...checks.filter((c) => (c.fieldIds ?? []).length === 1 && c.fieldIds[0] === field.id).map((c) => c.expr),
|
|
241
|
+
].map(readCheck);
|
|
242
|
+
const allowedValues = sources.find((source) => source.values)?.values;
|
|
243
|
+
const hasOtherCheck = sources.some((source) => source.other);
|
|
244
|
+
return {
|
|
245
|
+
id: field.id,
|
|
246
|
+
name: field.name,
|
|
247
|
+
category: field.category,
|
|
248
|
+
order: typeof field.fieldOrder === 'number' ? field.fieldOrder : index,
|
|
249
|
+
type: normalizeType(field.type),
|
|
250
|
+
rawType: asRecord(field.type),
|
|
251
|
+
isRequired: Boolean(field.isRequired),
|
|
252
|
+
default: columnDefault(field.defaultValue),
|
|
253
|
+
...(allowedValues ? { allowedValues } : {}),
|
|
254
|
+
...(hasOtherCheck ? { hasOtherCheck } : {}),
|
|
255
|
+
isPrimaryKey: pkIds.has(field.id),
|
|
256
|
+
isUnique: uniqueIds.has(field.id),
|
|
257
|
+
generated: Boolean(field.generationType || field.identityGeneration),
|
|
258
|
+
...(field.label ? { label: field.label } : {}),
|
|
259
|
+
...(field.description ? { description: field.description } : {}),
|
|
260
|
+
...(field.smartTags ? { smartTags: field.smartTags } : {}),
|
|
261
|
+
};
|
|
262
|
+
})
|
|
263
|
+
.sort((a, b) => a.order - b.order || a.name.localeCompare(b.name));
|
|
264
|
+
const policies = (policiesByTable.get(table.id) ?? [])
|
|
265
|
+
.filter((policy) => !isInternalPolicy(policy.policyType))
|
|
266
|
+
.map((policy) => ({
|
|
267
|
+
id: policy.id,
|
|
268
|
+
type: policy.policyType ?? '',
|
|
269
|
+
privilege: (policy.privilege ?? '').toLowerCase(),
|
|
270
|
+
role: policy.granteeName ?? '',
|
|
271
|
+
permissive: policy.permissive !== false,
|
|
272
|
+
disabled: policy.disabled === true,
|
|
273
|
+
data: asRecord(policy.data),
|
|
274
|
+
withCheck: asRecord(policy.withCheck),
|
|
275
|
+
}));
|
|
276
|
+
return {
|
|
277
|
+
id: table.id,
|
|
278
|
+
name: table.name,
|
|
279
|
+
category: table.category,
|
|
280
|
+
...(table.label ? { label: table.label } : {}),
|
|
281
|
+
columns,
|
|
282
|
+
foreignKeys: (fksByTable.get(table.id) ?? []).map((fk) => {
|
|
283
|
+
const target = fk.refTableId ? tablesById.get(fk.refTableId) : undefined;
|
|
284
|
+
return {
|
|
285
|
+
id: fk.id,
|
|
286
|
+
...(fk.name ? { name: fk.name } : {}),
|
|
287
|
+
columnIds: fk.fieldIds ?? [],
|
|
288
|
+
columns: names(fk.fieldIds),
|
|
289
|
+
targetTableId: fk.refTableId ?? '',
|
|
290
|
+
targetTable: target?.name ?? '',
|
|
291
|
+
targetCategory: target?.category ?? null,
|
|
292
|
+
targetColumns: names(fk.refFieldIds),
|
|
293
|
+
onDelete: fkAction(fk.deleteAction),
|
|
294
|
+
onUpdate: fkAction(fk.updateAction),
|
|
295
|
+
};
|
|
296
|
+
}),
|
|
297
|
+
uniqueConstraints: uniques.map((u) => ({ id: u.id, columns: names(u.fieldIds) })),
|
|
298
|
+
access: accessOf(policies),
|
|
299
|
+
policies,
|
|
300
|
+
};
|
|
301
|
+
});
|
|
302
|
+
return { databaseId, hash, tables };
|
|
303
|
+
}
|
|
@@ -5,10 +5,10 @@ declare const AddPoliciesZod: z.ZodObject<{
|
|
|
5
5
|
policies: z.ZodArray<z.ZodObject<{
|
|
6
6
|
policy_type: z.ZodString;
|
|
7
7
|
operations: z.ZodArray<z.ZodEnum<{
|
|
8
|
+
delete: "delete";
|
|
8
9
|
create: "create";
|
|
9
10
|
read: "read";
|
|
10
11
|
update: "update";
|
|
11
|
-
delete: "delete";
|
|
12
12
|
}>>;
|
|
13
13
|
role_name: z.ZodOptional<z.ZodString>;
|
|
14
14
|
is_permissive: z.ZodOptional<z.ZodBoolean>;
|
package/esm/tools/add-records.js
CHANGED
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
import { buildPostGraphileCreate, toCamelCaseSingular, toCreateMutationName, } from '@constructive-io/graphql-query/generators';
|
|
2
|
-
import { objects } from '@constructive-io/sdk';
|
|
3
2
|
import { z } from 'zod';
|
|
4
3
|
import { resolveDataToken, resolveProjectContext } from '../context';
|
|
4
|
+
import { classifyBackendErrors, errorsText, statusAdapter } from '../graphql-http';
|
|
5
5
|
import { cleanTable, findMetaTable, META_QUERY } from '../records/meta';
|
|
6
6
|
function fail(message) {
|
|
7
7
|
return { content: [{ type: 'text', text: message }], details: { success: false, message } };
|
|
8
8
|
}
|
|
9
|
+
const UNAVAILABLE = "The app database is unavailable: the backend answered ACCESS_SUSPENDED, so it was deleted or is suspended. Retrying won't help until it is restored.";
|
|
10
|
+
function unavailable(inserted = 0) {
|
|
11
|
+
const message = inserted > 0 ? `${UNAVAILABLE} ${inserted} row(s) were inserted before it stopped.` : `${UNAVAILABLE} No rows were inserted.`;
|
|
12
|
+
return {
|
|
13
|
+
content: [{ type: 'text', text: message }],
|
|
14
|
+
details: { success: false, message, code: 'database-unavailable', ...(inserted > 0 ? { createdCount: inserted } : {}) },
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
const isSuspended = (errors) => classifyBackendErrors(errors) === 'suspended';
|
|
9
18
|
function needsAuth(message) {
|
|
10
19
|
return {
|
|
11
20
|
content: [{ type: 'text', text: message }],
|
|
@@ -50,14 +59,15 @@ export const addRecordsTool = {
|
|
|
50
59
|
const token = await resolveDataToken(resolved.context);
|
|
51
60
|
if (!token.token)
|
|
52
61
|
return needsAuth(token.reason ?? 'Sign in to the database to insert records.');
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
});
|
|
62
|
+
// Keeps the status and extensions.code that the SDK adapter drops.
|
|
63
|
+
const adapter = statusAdapter(dataEndpoint, { Authorization: `Bearer ${token.token}` });
|
|
56
64
|
let tables;
|
|
57
65
|
try {
|
|
58
66
|
const meta = await adapter.execute(META_QUERY);
|
|
59
67
|
if (!meta.ok) {
|
|
60
|
-
|
|
68
|
+
if (isSuspended(meta.errors))
|
|
69
|
+
return unavailable();
|
|
70
|
+
return fail(`Failed to read schema from the app database: ${errorsText(meta.errors)}`);
|
|
61
71
|
}
|
|
62
72
|
tables = (meta.data._meta?.tables ?? []).filter((t) => Boolean(t));
|
|
63
73
|
}
|
|
@@ -88,7 +98,9 @@ export const addRecordsTool = {
|
|
|
88
98
|
try {
|
|
89
99
|
const result = await adapter.execute(mutation, variables);
|
|
90
100
|
if (!result.ok) {
|
|
91
|
-
|
|
101
|
+
if (isSuspended(result.errors))
|
|
102
|
+
return unavailable(created.length);
|
|
103
|
+
errors.push(errorsText(result.errors));
|
|
92
104
|
continue;
|
|
93
105
|
}
|
|
94
106
|
const payload = result.data[mutationName];
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { expandBlueprintDefaults } from '@agentic-kit/harness';
|
|
2
|
+
import { buildBlueprint } from '../blueprint-construction';
|
|
2
3
|
import { resolveProjectContext } from '../context';
|
|
3
4
|
import { resolveSchema, resolveTable } from '../schema-resolve';
|
|
4
5
|
import { AddRelationZod, buildRelation } from './add-relation-schema';
|
|
@@ -55,9 +56,7 @@ export const addRelationTool = {
|
|
|
55
56
|
const blueprintId = created.createBlueprint?.blueprint?.id;
|
|
56
57
|
if (!blueprintId)
|
|
57
58
|
throw new Error('Failed to create relation blueprint');
|
|
58
|
-
await modules.
|
|
59
|
-
.constructBlueprint({ input: { blueprintId, schemaId } }, { select: { result: true } })
|
|
60
|
-
.unwrap();
|
|
59
|
+
await buildBlueprint(modules, { blueprintId, schemaId }, { signal: ctx.signal });
|
|
61
60
|
}
|
|
62
61
|
catch (err) {
|
|
63
62
|
return fail(`Failed to add relation: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -17,6 +17,8 @@ export type CreateApiKeyDetails = {
|
|
|
17
17
|
principalName?: string;
|
|
18
18
|
scope?: string;
|
|
19
19
|
needsAuth?: boolean;
|
|
20
|
+
/** The app API answered ACCESS_SUSPENDED: the database is deleted or suspended. */
|
|
21
|
+
code?: 'database-unavailable';
|
|
20
22
|
};
|
|
21
23
|
export declare function toEnvVar(keyName: string): string;
|
|
22
24
|
export declare function isStepUpError(messages: string[]): boolean;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { auth } from '@constructive-io/sdk';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { deriveSubdomainEndpoint, resolveDataToken, resolveProjectContext } from '../context';
|
|
4
|
+
import { classifyBackendErrors, errorsText, postGraphQL, statusAdapter, } from '../graphql-http';
|
|
4
5
|
import { getHost } from '../host';
|
|
5
6
|
const CreateApiKeyZod = z.object({
|
|
6
7
|
key_name: z
|
|
@@ -51,19 +52,25 @@ export function describeScope(entityIds, readOnly) {
|
|
|
51
52
|
parts.push('read-only');
|
|
52
53
|
return parts.join(', ');
|
|
53
54
|
}
|
|
55
|
+
// The plain fetch, read at call time. postGraphQL keeps a 403's body, so a
|
|
56
|
+
// deleted or suspended database's ACCESS_SUSPENDED reaches the caller.
|
|
54
57
|
async function rawGraphql(endpoint, token, query, variables) {
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
headers: {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
body: JSON.stringify({ query, variables }),
|
|
58
|
+
const result = await postGraphQL({
|
|
59
|
+
endpoint,
|
|
60
|
+
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
|
61
|
+
query,
|
|
62
|
+
variables,
|
|
63
|
+
fetchFn: (input, init) => fetch(input, init),
|
|
62
64
|
});
|
|
63
|
-
return
|
|
65
|
+
return { data: result.data ?? undefined, errors: result.errors.length > 0 ? result.errors : undefined };
|
|
64
66
|
}
|
|
67
|
+
const UNAVAILABLE_MESSAGE = 'The app database is unavailable: the backend answered ACCESS_SUSPENDED, so it was deleted or is suspended. No key was minted.';
|
|
68
|
+
const isSuspended = (errors) => Boolean(errors?.length) && classifyBackendErrors(errors) === 'suspended';
|
|
69
|
+
const unavailable = () => fail(UNAVAILABLE_MESSAGE, { code: 'database-unavailable' });
|
|
65
70
|
async function createPrincipalInputFields(endpoint) {
|
|
66
71
|
const probe = await rawGraphql(endpoint, undefined, '{ __type(name: "CreatePrincipalInput") { inputFields { name } } }');
|
|
72
|
+
if (isSuspended(probe.errors))
|
|
73
|
+
return 'unavailable';
|
|
67
74
|
const type = probe.data?.__type;
|
|
68
75
|
return new Set((type?.inputFields ?? []).map((f) => f.name));
|
|
69
76
|
}
|
|
@@ -101,6 +108,8 @@ export const createApiKeyTool = {
|
|
|
101
108
|
try {
|
|
102
109
|
if (scoped) {
|
|
103
110
|
const fields = await createPrincipalInputFields(authEndpoint);
|
|
111
|
+
if (fields === 'unavailable')
|
|
112
|
+
return unavailable();
|
|
104
113
|
const missing = [
|
|
105
114
|
...(params.entity_ids?.length && !fields.has('entityIds') ? ['entityIds'] : []),
|
|
106
115
|
...(params.read_only === true && !fields.has('isReadOnly') ? ['isReadOnly'] : []),
|
|
@@ -110,8 +119,7 @@ export const createApiKeyTool = {
|
|
|
110
119
|
}
|
|
111
120
|
}
|
|
112
121
|
const dbAuth = auth.createClient({
|
|
113
|
-
|
|
114
|
-
headers: { Authorization: `Bearer ${token.token}` },
|
|
122
|
+
adapter: statusAdapter(authEndpoint, { Authorization: `Bearer ${token.token}` }),
|
|
115
123
|
});
|
|
116
124
|
const principalName = params.principal_name?.trim() || keyName;
|
|
117
125
|
const existing = await dbAuth.principal
|
|
@@ -148,8 +156,10 @@ export const createApiKeyTool = {
|
|
|
148
156
|
...(params.read_only !== undefined && { isReadOnly: params.read_only }),
|
|
149
157
|
},
|
|
150
158
|
});
|
|
159
|
+
if (isSuspended(created.errors))
|
|
160
|
+
return unavailable();
|
|
151
161
|
if (created.errors?.length) {
|
|
152
|
-
return fail(`createPrincipal failed: ${created.errors
|
|
162
|
+
return fail(`createPrincipal failed: ${errorsText(created.errors)}`);
|
|
153
163
|
}
|
|
154
164
|
const payload = created.data?.createPrincipal;
|
|
155
165
|
principalId = payload?.result;
|
|
@@ -177,7 +187,9 @@ export const createApiKeyTool = {
|
|
|
177
187
|
minted = await mint();
|
|
178
188
|
}
|
|
179
189
|
if (!minted.ok) {
|
|
180
|
-
|
|
190
|
+
if (isSuspended(minted.errors))
|
|
191
|
+
return unavailable();
|
|
192
|
+
return fail(`createApiKey failed: ${errorsText(minted.errors)}`);
|
|
181
193
|
}
|
|
182
194
|
const record = minted.data?.createApiKey?.result;
|
|
183
195
|
if (!record?.apiKey || !record.keyId)
|
|
@@ -208,6 +220,9 @@ export const createApiKeyTool = {
|
|
|
208
220
|
};
|
|
209
221
|
}
|
|
210
222
|
catch (err) {
|
|
223
|
+
// The SDK's unwrap() throws its errors as `errors`.
|
|
224
|
+
if (isSuspended(err?.errors))
|
|
225
|
+
return unavailable();
|
|
211
226
|
return fail(err instanceof Error ? err.message : 'Failed to create the API key.');
|
|
212
227
|
}
|
|
213
228
|
},
|
|
@@ -2,8 +2,8 @@ import type { HarnessTool } from '@agentic-kit/harness';
|
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
declare const ManageEntityTypesZod: z.ZodObject<{
|
|
4
4
|
action: z.ZodEnum<{
|
|
5
|
-
create: "create";
|
|
6
5
|
delete: "delete";
|
|
6
|
+
create: "create";
|
|
7
7
|
list: "list";
|
|
8
8
|
}>;
|
|
9
9
|
name: z.ZodOptional<z.ZodString>;
|
|
@@ -16,6 +16,12 @@ export type ProvisionBlueprintDetails = {
|
|
|
16
16
|
total: number;
|
|
17
17
|
tables: ProvisionedTable[];
|
|
18
18
|
error: string | null;
|
|
19
|
+
/**
|
|
20
|
+
* The construction the backend queued, also when the build then failed or
|
|
21
|
+
* the wait gave up: the build carries on, so calling again would build the
|
|
22
|
+
* same tables twice. Null when nothing was queued.
|
|
23
|
+
*/
|
|
24
|
+
constructionId: string | null;
|
|
19
25
|
};
|
|
20
26
|
export declare const provisionBlueprintTool: HarnessTool<typeof BlueprintZod, ProvisionBlueprintDetails>;
|
|
21
27
|
export {};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { BlueprintZod, expandBlueprintDefaults, filterInternalPolicies, } from '@agentic-kit/harness';
|
|
2
|
+
import { BlueprintBuildError, buildBlueprint } from '../blueprint-construction';
|
|
2
3
|
import { resolveProjectContext } from '../context';
|
|
3
4
|
function formatText(name, details) {
|
|
4
5
|
if (details.error)
|
|
@@ -23,7 +24,13 @@ export const provisionBlueprintTool = {
|
|
|
23
24
|
promptSnippet: 'provision_blueprint: create new tables from scratch in a single call (fields + policies + relations). Call describe_schema first.',
|
|
24
25
|
parameters: BlueprintZod,
|
|
25
26
|
async execute(params, ctx) {
|
|
26
|
-
const empty = {
|
|
27
|
+
const empty = {
|
|
28
|
+
created: 0,
|
|
29
|
+
total: 0,
|
|
30
|
+
tables: [],
|
|
31
|
+
error: null,
|
|
32
|
+
constructionId: null,
|
|
33
|
+
};
|
|
27
34
|
const resolved = await resolveProjectContext(ctx.cwd);
|
|
28
35
|
if (!resolved.context) {
|
|
29
36
|
return { content: [{ type: 'text', text: resolved.reason }], details: empty };
|
|
@@ -54,9 +61,7 @@ export const provisionBlueprintTool = {
|
|
|
54
61
|
const blueprintId = createResult.createBlueprint?.blueprint?.id;
|
|
55
62
|
if (!blueprintId)
|
|
56
63
|
throw new Error('Failed to create blueprint');
|
|
57
|
-
await modules.
|
|
58
|
-
.constructBlueprint({ input: { blueprintId, schemaId } }, { select: { result: true } })
|
|
59
|
-
.unwrap();
|
|
64
|
+
const { constructionId } = await buildBlueprint(modules, { blueprintId, schemaId }, { signal: ctx.signal });
|
|
60
65
|
const relations = params.definition.relations ?? [];
|
|
61
66
|
const details = {
|
|
62
67
|
created: params.definition.tables.length,
|
|
@@ -73,6 +78,7 @@ export const provisionBlueprintTool = {
|
|
|
73
78
|
relationCount: relations.filter((r) => r.source_table === t.table_name).length,
|
|
74
79
|
})),
|
|
75
80
|
error: null,
|
|
81
|
+
constructionId,
|
|
76
82
|
};
|
|
77
83
|
return { content: [{ type: 'text', text: formatText(params.name, details) }], details };
|
|
78
84
|
}
|
|
@@ -83,6 +89,7 @@ export const provisionBlueprintTool = {
|
|
|
83
89
|
total: params.definition.tables.length,
|
|
84
90
|
tables: [],
|
|
85
91
|
error: message,
|
|
92
|
+
constructionId: err instanceof BlueprintBuildError ? err.constructionId : null,
|
|
86
93
|
};
|
|
87
94
|
return { content: [{ type: 'text', text: formatText(params.name, details) }], details };
|
|
88
95
|
}
|
|
@@ -1,17 +1,14 @@
|
|
|
1
1
|
import type { HarnessTool } from '@agentic-kit/harness';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
+
import { type RunCommand } from '../app-workspace';
|
|
4
|
+
import { type ProvisionDatabaseDetails } from '../provision-database/provision';
|
|
5
|
+
export type { ProvisionDatabaseDetails };
|
|
3
6
|
declare const ProvisionDatabaseZod: z.ZodObject<{
|
|
4
7
|
database_name: z.ZodString;
|
|
5
8
|
reprovision: z.ZodOptional<z.ZodBoolean>;
|
|
6
9
|
}, z.core.$strip>;
|
|
7
|
-
export
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
ownerId?: string;
|
|
13
|
-
/** True when an existing live binding was kept — nothing changed. */
|
|
14
|
-
skipped?: boolean;
|
|
15
|
-
};
|
|
16
|
-
export declare const provisionDatabaseTool: HarnessTool<typeof ProvisionDatabaseZod, ProvisionDatabaseDetails>;
|
|
17
|
-
export {};
|
|
10
|
+
export declare function createProvisionDatabaseTool(exec?: RunCommand): HarnessTool<typeof ProvisionDatabaseZod, ProvisionDatabaseDetails>;
|
|
11
|
+
export declare const provisionDatabaseTool: HarnessTool<z.ZodObject<{
|
|
12
|
+
database_name: z.ZodString;
|
|
13
|
+
reprovision: z.ZodOptional<z.ZodBoolean>;
|
|
14
|
+
}, z.core.$strip>, ProvisionDatabaseDetails>;
|