@zveltio/sdk 3.0.0-beta.4 → 3.0.0-beta.68
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/dist/client/Auth.d.ts.map +1 -1
- package/dist/client/Auth.js +3 -0
- package/dist/client/QueryBuilder.d.ts.map +1 -1
- package/dist/client/QueryBuilder.js +4 -0
- package/dist/client/ZveltioClient.d.ts.map +1 -1
- package/dist/client/ZveltioClient.js +5 -0
- package/dist/client.d.ts +4 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +30 -13
- package/dist/core.d.ts.map +1 -1
- package/dist/core.js +21 -3
- package/dist/crdt.d.ts.map +1 -1
- package/dist/crdt.js +11 -2
- package/dist/ddl/index.d.ts.map +1 -1
- package/dist/errors.d.ts +58 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +124 -0
- package/dist/extension/index.d.ts +419 -19
- package/dist/extension/index.d.ts.map +1 -1
- package/dist/extension/index.js +3 -0
- package/dist/extension/jsonb.d.ts +62 -0
- package/dist/extension/jsonb.d.ts.map +1 -0
- package/dist/extension/jsonb.js +64 -0
- package/dist/extension/multipart.d.ts +58 -0
- package/dist/extension/multipart.d.ts.map +1 -0
- package/dist/extension/multipart.js +61 -0
- package/dist/extension/numeric.d.ts +94 -0
- package/dist/extension/numeric.d.ts.map +1 -0
- package/dist/extension/numeric.js +162 -0
- package/dist/extension/permission-gate.d.ts +15 -0
- package/dist/extension/permission-gate.d.ts.map +1 -1
- package/dist/extension/permission-gate.js +33 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/local-store.d.ts +5 -0
- package/dist/local-store.d.ts.map +1 -1
- package/dist/local-store.js +62 -10
- package/dist/offline/index.d.ts +3 -3
- package/dist/offline/index.js +51 -15
- package/dist/publish/index.d.ts.map +1 -1
- package/dist/publish/index.js +6 -1
- package/dist/realtime.d.ts +9 -1
- package/dist/realtime.d.ts.map +1 -1
- package/dist/realtime.js +15 -2
- package/dist/rpc/index.d.ts.map +1 -1
- package/dist/rpc/index.js +2 -0
- package/dist/schema-codegen.d.ts +3 -1
- package/dist/schema-codegen.d.ts.map +1 -1
- package/dist/schema-codegen.js +41 -7
- package/dist/schema-watcher.d.ts +1 -1
- package/dist/schema-watcher.js +1 -1
- package/dist/studio/index.d.ts +1 -0
- package/dist/studio/index.d.ts.map +1 -1
- package/dist/studio/index.js +3 -0
- package/dist/studio/slot-owner-registry.d.ts +13 -0
- package/dist/studio/slot-owner-registry.d.ts.map +1 -0
- package/dist/studio/slot-owner-registry.js +21 -0
- package/dist/svelte.d.ts +1 -1
- package/dist/svelte.d.ts.map +1 -1
- package/dist/svelte.js +4 -2
- package/dist/sync-manager.d.ts +30 -4
- package/dist/sync-manager.d.ts.map +1 -1
- package/dist/sync-manager.js +101 -15
- package/dist/testing/index.d.ts.map +1 -1
- package/dist/testing/index.js +23 -1
- package/dist/testing/with-test-db.d.ts.map +1 -1
- package/dist/testing/with-test-db.js +21 -14
- package/dist/tests/client.test.d.ts +2 -0
- package/dist/tests/client.test.d.ts.map +1 -0
- package/dist/tests/client.test.js +55 -0
- package/dist/tests/crdt-orset.test.d.ts +2 -0
- package/dist/tests/crdt-orset.test.d.ts.map +1 -0
- package/dist/tests/crdt-orset.test.js +23 -0
- package/dist/tests/local-store.test.js +44 -0
- package/dist/tests/multipart.test.d.ts +2 -0
- package/dist/tests/multipart.test.d.ts.map +1 -0
- package/dist/tests/multipart.test.js +37 -0
- package/dist/tests/offline-provider-crdt.test.d.ts +2 -0
- package/dist/tests/offline-provider-crdt.test.d.ts.map +1 -0
- package/dist/tests/offline-provider-crdt.test.js +119 -0
- package/dist/tests/realtime.test.d.ts +2 -0
- package/dist/tests/realtime.test.d.ts.map +1 -0
- package/dist/tests/realtime.test.js +39 -0
- package/dist/tests/sync-manager.test.d.ts +2 -0
- package/dist/tests/sync-manager.test.d.ts.map +1 -0
- package/dist/tests/sync-manager.test.js +65 -0
- package/dist/tests/with-test-db.test.d.ts +2 -0
- package/dist/tests/with-test-db.test.d.ts.map +1 -0
- package/dist/tests/with-test-db.test.js +57 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/validate/index.d.ts +49 -0
- package/dist/validate/index.d.ts.map +1 -1
- package/dist/validate/index.js +494 -7
- package/dist/validate/shared-message-keys.d.ts +16 -0
- package/dist/validate/shared-message-keys.d.ts.map +1 -0
- package/dist/validate/shared-message-keys.js +227 -0
- package/package.json +7 -7
- package/dist/generate-types.d.ts +0 -37
- package/dist/generate-types.d.ts.map +0 -1
- package/dist/generate-types.js +0 -157
package/dist/validate/index.js
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
* which can call validate as a sub-step.
|
|
25
25
|
*/
|
|
26
26
|
import { parseMigrationSql } from './migration-parse.js';
|
|
27
|
+
export { SHARED_MESSAGE_KEYS } from './shared-message-keys.js';
|
|
27
28
|
// ─── Helpers ───────────────────────────────────────────────────────────────
|
|
28
29
|
const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?$/;
|
|
29
30
|
const KNOWN_CATEGORIES = new Set([
|
|
@@ -62,6 +63,9 @@ const SAFE_VERSION_RANGE = /^[\d.*^~>=<| -]+$/;
|
|
|
62
63
|
function err(code, message, file) {
|
|
63
64
|
return file ? { code, message, file } : { code, message };
|
|
64
65
|
}
|
|
66
|
+
function warn(code, message, file) {
|
|
67
|
+
return { ...err(code, message, file), severity: 'warning' };
|
|
68
|
+
}
|
|
65
69
|
export function validateManifest(input) {
|
|
66
70
|
const out = [];
|
|
67
71
|
const m = input.manifest;
|
|
@@ -90,9 +94,10 @@ export function validateManifest(input) {
|
|
|
90
94
|
out.push(err('MANIFEST_BAD_MAX_VERSION', `zveltioMaxVersion "${obj.zveltioMaxVersion}" is not valid semver`));
|
|
91
95
|
}
|
|
92
96
|
if (typeof obj.category === 'string' && !KNOWN_CATEGORIES.has(obj.category)) {
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
|
|
97
|
+
// A true warning — categories are open-ended in practice (e.g. the RO
|
|
98
|
+
// compliance set uses "compliance/ro"). Surfaced so authors notice typos
|
|
99
|
+
// like "finanace", but it must NOT fail validation.
|
|
100
|
+
out.push(warn('MANIFEST_UNKNOWN_CATEGORY', `category "${obj.category}" isn't in the known set — typo? Known: ${[...KNOWN_CATEGORIES].sort().join(', ')}`));
|
|
96
101
|
}
|
|
97
102
|
return out;
|
|
98
103
|
}
|
|
@@ -126,18 +131,178 @@ export function validatePeerDependencies(input) {
|
|
|
126
131
|
*/
|
|
127
132
|
function hasDestructiveDdl(upSql) {
|
|
128
133
|
// Strip strings and comments-light: this is a heuristic; full SQL parsing
|
|
129
|
-
// is overkill for a pre-publish lint.
|
|
134
|
+
// is overkill for a pre-publish lint. Only DATA-destructive ops count —
|
|
135
|
+
// DROP TABLE / DROP COLUMN lose rows. `ALTER COLUMN ... DROP NOT NULL` and
|
|
136
|
+
// `DROP DEFAULT` only relax a constraint / remove a default and lose no data,
|
|
137
|
+
// so they do NOT require a -- DOWN (they were false positives on legit
|
|
138
|
+
// forward migrations that make a column nullable).
|
|
130
139
|
const s = upSql.replace(/--[^\n]*/g, '').replace(/\/\*[\s\S]*?\*\//g, '');
|
|
131
140
|
if (/\bDROP\s+TABLE\b/i.test(s))
|
|
132
141
|
return true;
|
|
133
142
|
if (/\bDROP\s+COLUMN\b/i.test(s))
|
|
134
143
|
return true;
|
|
135
|
-
if (/\bALTER\s+COLUMN\b[\s\S]*?\bDROP\s+(NOT\s+NULL|DEFAULT)\b/i.test(s))
|
|
136
|
-
return true;
|
|
137
144
|
return false;
|
|
138
145
|
}
|
|
146
|
+
/**
|
|
147
|
+
* Columns that record WHO did something, declared as uuid.
|
|
148
|
+
*
|
|
149
|
+
* `"user".id` is a 32-character nanoid, not a uuid. A column named `created_by`
|
|
150
|
+
* or `checked_by` reads like a foreign key to a table whose primary key happens
|
|
151
|
+
* to be one, so eleven of them were declared UUID across this codebase and
|
|
152
|
+
* Postgres answered 22P02 to every write. Creating a document, creating a
|
|
153
|
+
* purchase order, ticking an item off a checklist — none of them ever worked on
|
|
154
|
+
* any installation.
|
|
155
|
+
*
|
|
156
|
+
* Caught here rather than explained in a guide because an extension author has
|
|
157
|
+
* no way to know this. It is a property of the host's user table, it fails only
|
|
158
|
+
* at runtime, and the error names the type rather than the cause.
|
|
159
|
+
*/
|
|
160
|
+
function userRefUuidColumns(upSql) {
|
|
161
|
+
const s = upSql.replace(/--[^\n]*/g, '').replace(/\/\*[\s\S]*?\*\//g, '');
|
|
162
|
+
const found = new Set();
|
|
163
|
+
// `foo_by UUID` in a CREATE TABLE body, or an ADD COLUMN of the same shape.
|
|
164
|
+
for (const m of s.matchAll(/(\b[a-z_][a-z0-9_]*_by)\s+UUID\b/gi)) {
|
|
165
|
+
found.add(m[1].toLowerCase());
|
|
166
|
+
}
|
|
167
|
+
// `ALTER COLUMN foo_by TYPE UUID` — the same mistake, made later.
|
|
168
|
+
for (const m of s.matchAll(/ALTER\s+COLUMN\s+(\b[a-z_][a-z0-9_]*_by)\s+TYPE\s+UUID\b/gi)) {
|
|
169
|
+
found.add(m[1].toLowerCase());
|
|
170
|
+
}
|
|
171
|
+
return [...found];
|
|
172
|
+
}
|
|
173
|
+
/** `ALTER COLUMN foo_by TYPE TEXT` — a later migration putting one right. */
|
|
174
|
+
function userRefFixedColumns(upSql) {
|
|
175
|
+
const s = upSql.replace(/--[^\n]*/g, '').replace(/\/\*[\s\S]*?\*\//g, '');
|
|
176
|
+
return [
|
|
177
|
+
...new Set([...s.matchAll(/ALTER\s+COLUMN\s+(\b[a-z_][a-z0-9_]*_by)\s+TYPE\s+TEXT\b/gi)].map((m) => m[1].toLowerCase())),
|
|
178
|
+
];
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Unique keys over a natural column that leave out `tenant_id`.
|
|
182
|
+
*
|
|
183
|
+
* Sixty of these predated multi-tenancy here. The effect is that the SECOND
|
|
184
|
+
* company on a shared instance is refused a value the first one took — its own
|
|
185
|
+
* invoice number, its own product code, its own fiscal year — and because
|
|
186
|
+
* row-level security hides the conflicting row, it is shown a database error
|
|
187
|
+
* about something it cannot see and cannot resolve.
|
|
188
|
+
*
|
|
189
|
+
* Only flags keys made of plain names. Deliberately silent about:
|
|
190
|
+
* - `id` alone, since a uuid is unique everywhere already;
|
|
191
|
+
* - anything with `token`, `hash` or `secret` in it, which MUST collide across
|
|
192
|
+
* the whole instance — that is the point of them;
|
|
193
|
+
* - keys made of `*_id` columns, because a uuid parent already belongs to one
|
|
194
|
+
* company, so the child cannot straddle two.
|
|
195
|
+
*/
|
|
196
|
+
/** Top-level items of every `CREATE TABLE ... ( … )` body in `sql`. */
|
|
197
|
+
function createTableColumnDefs(sql) {
|
|
198
|
+
const out = [];
|
|
199
|
+
const re = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?[^(]*\(/gi;
|
|
200
|
+
let m;
|
|
201
|
+
// biome-ignore lint/suspicious/noAssignInExpressions: standard regex loop
|
|
202
|
+
while ((m = re.exec(sql)) !== null) {
|
|
203
|
+
let depth = 1;
|
|
204
|
+
let item = '';
|
|
205
|
+
let i = m.index + m[0].length;
|
|
206
|
+
for (; i < sql.length && depth > 0; i++) {
|
|
207
|
+
const ch = sql[i];
|
|
208
|
+
if (ch === '(')
|
|
209
|
+
depth++;
|
|
210
|
+
else if (ch === ')') {
|
|
211
|
+
depth--;
|
|
212
|
+
if (depth === 0)
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
if (depth === 1 && ch === ',') {
|
|
216
|
+
out.push(item);
|
|
217
|
+
item = '';
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
item += ch;
|
|
221
|
+
}
|
|
222
|
+
out.push(item);
|
|
223
|
+
re.lastIndex = i;
|
|
224
|
+
}
|
|
225
|
+
return out;
|
|
226
|
+
}
|
|
227
|
+
/** Column names carrying an inline `UNIQUE` constraint. */
|
|
228
|
+
function columnLevelUniqueColumns(sql) {
|
|
229
|
+
const out = [];
|
|
230
|
+
for (const def of createTableColumnDefs(sql)) {
|
|
231
|
+
const t = def.trim();
|
|
232
|
+
// Strip string literals first: `CHECK (rule_type IN ('unique', …))` is a
|
|
233
|
+
// column whose ALLOWED VALUES include the word, not a unique constraint.
|
|
234
|
+
const bare = t.replace(/'(?:[^']|'')*'/g, "''");
|
|
235
|
+
if (!/\bUNIQUE\b(?!\s*\()/i.test(bare))
|
|
236
|
+
continue;
|
|
237
|
+
const name = /^([a-z_][a-z0-9_]*)\s+\S/i.exec(t)?.[1];
|
|
238
|
+
if (!name)
|
|
239
|
+
continue;
|
|
240
|
+
if (/^(?:primary|unique|constraint|foreign|check|exclude|like)$/i.test(name))
|
|
241
|
+
continue;
|
|
242
|
+
out.push(name);
|
|
243
|
+
}
|
|
244
|
+
return out;
|
|
245
|
+
}
|
|
246
|
+
function keyColumnSets(upSql) {
|
|
247
|
+
const s = upSql.replace(/--[^\n]*/g, '').replace(/\/\*[\s\S]*?\*\//g, '');
|
|
248
|
+
const sets = [];
|
|
249
|
+
const add = (cols) => sets.push(cols
|
|
250
|
+
.split(',')
|
|
251
|
+
.map((c) => c.trim().replace(/["`]/g, '').toLowerCase())
|
|
252
|
+
.filter(Boolean));
|
|
253
|
+
for (const m of s.matchAll(/\bUNIQUE\s*\(([^)]+)\)/gi))
|
|
254
|
+
add(m[1]);
|
|
255
|
+
for (const m of s.matchAll(/\bPRIMARY\s+KEY\s*\(([^)]+)\)/gi))
|
|
256
|
+
add(m[1]);
|
|
257
|
+
for (const m of s.matchAll(/CREATE\s+UNIQUE\s+INDEX[^(]*\(([^)]+)\)/gi))
|
|
258
|
+
add(m[1]);
|
|
259
|
+
// Column-level `sku TEXT NOT NULL UNIQUE` — the same constraint, written
|
|
260
|
+
// inline. Fifty-eight of these are in the shipped extensions and every one was
|
|
261
|
+
// invisible here, because the three patterns above all require a parenthesised
|
|
262
|
+
// column list. A `UNIQUE` with no `(` after it is a column constraint, and the
|
|
263
|
+
// column is the name the definition opens with.
|
|
264
|
+
for (const col of columnLevelUniqueColumns(s))
|
|
265
|
+
add(col);
|
|
266
|
+
for (const m of s.matchAll(/\bADD\s+COLUMN\s+(?:IF\s+NOT\s+EXISTS\s+)?([a-z_][a-z0-9_]*)[^,;\n]*?\bUNIQUE\b(?!\s*\()/gi))
|
|
267
|
+
add(m[1]);
|
|
268
|
+
return sets;
|
|
269
|
+
}
|
|
270
|
+
/** Narrow: a key over plain names with no `tenant_id`. Returns the joined columns. */
|
|
271
|
+
function narrowKey(parts) {
|
|
272
|
+
if (parts.length === 0)
|
|
273
|
+
return null;
|
|
274
|
+
if (parts.includes('tenant_id'))
|
|
275
|
+
return null;
|
|
276
|
+
if (parts.length === 1 && parts[0] === 'id')
|
|
277
|
+
return null;
|
|
278
|
+
if (parts.some((p) => /token|hash|secret/.test(p)))
|
|
279
|
+
return null;
|
|
280
|
+
// ANY `*_id` in the key is enough. It references a row that already belongs to
|
|
281
|
+
// one company, so the key cannot straddle two — `(fiscal_year_id, account_id,
|
|
282
|
+
// month)` and `(product_id, warehouse_id, batch_number)` are correctly scoped
|
|
283
|
+
// without naming the tenant. Requiring EVERY part to be an id was too strict
|
|
284
|
+
// and flagged sixteen keys that a hand review had already accepted.
|
|
285
|
+
if (parts.some((p) => /_id$/.test(p)))
|
|
286
|
+
return null;
|
|
287
|
+
// Sorted, so a later migration that re-adds the key with its columns in a
|
|
288
|
+
// different order still counts as the fix for this one.
|
|
289
|
+
return [...parts].sort().join(', ');
|
|
290
|
+
}
|
|
291
|
+
/** Wide: the same key WITH `tenant_id`, which is a later migration fixing it. */
|
|
292
|
+
function widenedKey(parts) {
|
|
293
|
+
if (!parts.includes('tenant_id'))
|
|
294
|
+
return null;
|
|
295
|
+
const rest = parts.filter((p) => p !== 'tenant_id').sort();
|
|
296
|
+
return rest.length > 0 ? rest.join(', ') : null;
|
|
297
|
+
}
|
|
139
298
|
export function validateMigrations(input) {
|
|
140
299
|
const out = [];
|
|
300
|
+
// Accumulated across every file, because a migration set is a sequence and the
|
|
301
|
+
// only thing that matters is where it ends up.
|
|
302
|
+
const narrowKeys = [];
|
|
303
|
+
const widened = new Set();
|
|
304
|
+
const uuidRefs = [];
|
|
305
|
+
const fixedRefs = new Set();
|
|
141
306
|
for (const file of input.files) {
|
|
142
307
|
if (file.sql.trim() === '') {
|
|
143
308
|
out.push(err('MIGRATION_EMPTY', 'migration file is empty', file.filename));
|
|
@@ -158,6 +323,41 @@ export function validateMigrations(input) {
|
|
|
158
323
|
if (input.requireDownForDestructive && hasDestructiveDdl(parsed.up) && !parsed.down) {
|
|
159
324
|
out.push(err('MIGRATION_DESTRUCTIVE_NO_DOWN', 'migration contains destructive DDL (DROP TABLE/COLUMN or ALTER ... DROP) but has no "-- DOWN" section. Add one so uninstall --purgeData can roll back.', file.filename));
|
|
160
325
|
}
|
|
326
|
+
// Schema shape is judged on the END STATE, below, not file by file: an
|
|
327
|
+
// extension that declared a column wrong in 001 and put it right in 006 is
|
|
328
|
+
// correct, and complaining about 001 would only teach people to edit
|
|
329
|
+
// history.
|
|
330
|
+
narrowKeys.push(...keyColumnSets(parsed.up)
|
|
331
|
+
.map(narrowKey)
|
|
332
|
+
.filter((k) => k !== null)
|
|
333
|
+
.map((cols) => ({ cols, filename: file.filename })));
|
|
334
|
+
for (const parts of keyColumnSets(parsed.up)) {
|
|
335
|
+
const wide = widenedKey(parts);
|
|
336
|
+
if (wide)
|
|
337
|
+
widened.add(wide);
|
|
338
|
+
}
|
|
339
|
+
uuidRefs.push(...userRefUuidColumns(parsed.up).map((column) => ({ column, filename: file.filename })));
|
|
340
|
+
for (const column of userRefFixedColumns(parsed.up))
|
|
341
|
+
fixedRefs.add(column);
|
|
342
|
+
}
|
|
343
|
+
for (const { column, filename } of uuidRefs) {
|
|
344
|
+
if (fixedRefs.has(column))
|
|
345
|
+
continue;
|
|
346
|
+
out.push(err('SCHEMA_USER_REF_UUID', `"${column}" is declared UUID and its name says it records a user. ` +
|
|
347
|
+
'`"user".id` is a 32-character nanoid, so Postgres rejects every write with ' +
|
|
348
|
+
'22P02 "invalid input syntax for type uuid" — the route then fails for ' +
|
|
349
|
+
'everybody, always, with an error naming the type rather than the cause. ' +
|
|
350
|
+
'Declare it TEXT.', filename));
|
|
351
|
+
}
|
|
352
|
+
for (const { cols, filename } of narrowKeys) {
|
|
353
|
+
if (widened.has(cols))
|
|
354
|
+
continue;
|
|
355
|
+
out.push(err('SCHEMA_UNIQUE_WITHOUT_TENANT', `UNIQUE (${cols}) leaves out tenant_id. Several companies share one ` +
|
|
356
|
+
'instance, so the second one to use a value is refused it — its own invoice ' +
|
|
357
|
+
'number, its own product code, its own fiscal year — and row-level security ' +
|
|
358
|
+
'hides the conflicting row, so it is shown a database error about something ' +
|
|
359
|
+
`it cannot see and cannot resolve. Use UNIQUE (tenant_id, ${cols}) unless ` +
|
|
360
|
+
'the value must be unique across the whole instance, as a token or a hash is.', filename));
|
|
161
361
|
}
|
|
162
362
|
return out;
|
|
163
363
|
}
|
|
@@ -181,6 +381,292 @@ export function validateBundleSize(input) {
|
|
|
181
381
|
}
|
|
182
382
|
return [];
|
|
183
383
|
}
|
|
384
|
+
// ─── SDUI declarative pages: endpoint contract ─────────────────────────────
|
|
385
|
+
//
|
|
386
|
+
// Catches the class of bug that shipped silently in beta.11: a declarative
|
|
387
|
+
// schema whose `dataSource`/`endpoint` points at a route the extension does NOT
|
|
388
|
+
// serve (wrong path → empty table / 404), or at a foreign endpoint outside the
|
|
389
|
+
// extension's own `/ext/<name>/` namespace (security). The response-key check
|
|
390
|
+
// (`dataPath`) needs the live response, so it lives in the runtime
|
|
391
|
+
// `zveltio extension test`, not here.
|
|
392
|
+
/** Read-only core endpoints a schema is legitimately allowed to call (relation
|
|
393
|
+
* pickers etc.). Keep this list tiny and read-only. */
|
|
394
|
+
export const SDUI_SHARED_READ_ALLOWLIST = new Set([
|
|
395
|
+
'/api/collections',
|
|
396
|
+
'/api/views',
|
|
397
|
+
]);
|
|
398
|
+
/** Collapse `{token}` and `:param` path segments so a schema endpoint
|
|
399
|
+
* (`/periods/{id}/generate`) matches an engine route (`/periods/:id/generate`). */
|
|
400
|
+
function normalizeRoute(p) {
|
|
401
|
+
return (p
|
|
402
|
+
.split('?')[0]
|
|
403
|
+
.replace(/\{[^}]+\}/g, '*')
|
|
404
|
+
.replace(/:[^/]+/g, '*')
|
|
405
|
+
.replace(/\/+$/, '') || '/');
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* Schema keys whose value is user-visible text.
|
|
409
|
+
*
|
|
410
|
+
* The host renders these through `t()`, which looks the string up in the
|
|
411
|
+
* message bundle and falls back to the literal. That fallback is why a typo is
|
|
412
|
+
* invisible at runtime: the user simply sees `auth.saml.ui.begin_certificate`
|
|
413
|
+
* rendered as a placeholder. Catching it here is the only place it is cheap.
|
|
414
|
+
*/
|
|
415
|
+
export const SDUI_I18N_SLOTS = [
|
|
416
|
+
'title',
|
|
417
|
+
'subtitle',
|
|
418
|
+
'label',
|
|
419
|
+
'placeholder',
|
|
420
|
+
'note',
|
|
421
|
+
'confirm',
|
|
422
|
+
'description',
|
|
423
|
+
'emptyText',
|
|
424
|
+
'emptyTitle',
|
|
425
|
+
'hint',
|
|
426
|
+
];
|
|
427
|
+
/** Tokens that read the same in every locale we ship. */
|
|
428
|
+
const I18N_UNIVERSAL = new Set([
|
|
429
|
+
'API',
|
|
430
|
+
'JSON',
|
|
431
|
+
'NDJSON',
|
|
432
|
+
'CSV',
|
|
433
|
+
'SQL',
|
|
434
|
+
'URL',
|
|
435
|
+
'UUID',
|
|
436
|
+
'ID',
|
|
437
|
+
'HTTP',
|
|
438
|
+
'HTTPS',
|
|
439
|
+
'GET',
|
|
440
|
+
'POST',
|
|
441
|
+
'PUT',
|
|
442
|
+
'PATCH',
|
|
443
|
+
'DELETE',
|
|
444
|
+
'Zveltio',
|
|
445
|
+
'OK',
|
|
446
|
+
'UTC',
|
|
447
|
+
'AI',
|
|
448
|
+
'SDK',
|
|
449
|
+
'CLI',
|
|
450
|
+
'RLS',
|
|
451
|
+
'DB',
|
|
452
|
+
'PDF',
|
|
453
|
+
'XML',
|
|
454
|
+
'YAML',
|
|
455
|
+
]);
|
|
456
|
+
/**
|
|
457
|
+
* Third-party product names an integration has to name literally.
|
|
458
|
+
*
|
|
459
|
+
* A validator cannot tell `Notion` from `Furniture` — both are one capitalised
|
|
460
|
+
* word — so the judgement has to be written down. Translating a vendor's name
|
|
461
|
+
* would be wrong in every locale, and warning about it teaches authors to
|
|
462
|
+
* ignore the warning. Extend deliberately, one product at a time.
|
|
463
|
+
*/
|
|
464
|
+
const I18N_PRODUCT_NAMES = new Set([
|
|
465
|
+
'HubSpot',
|
|
466
|
+
'Notion',
|
|
467
|
+
'Airtable',
|
|
468
|
+
'Okta',
|
|
469
|
+
'Entra ID',
|
|
470
|
+
'Slack',
|
|
471
|
+
'Stripe',
|
|
472
|
+
'Salesforce',
|
|
473
|
+
'ANAF',
|
|
474
|
+
]);
|
|
475
|
+
/** A string built only from product names and separators, e.g. "Entra ID / Okta". */
|
|
476
|
+
function isProductNameList(s) {
|
|
477
|
+
const parts = s
|
|
478
|
+
.split(/[/,·|]/)
|
|
479
|
+
.map((p) => p.trim())
|
|
480
|
+
.filter(Boolean);
|
|
481
|
+
return parts.length > 0 && parts.every((p) => I18N_PRODUCT_NAMES.has(p));
|
|
482
|
+
}
|
|
483
|
+
/** Dotted lowercase identifier — the shape every message key in this project has. */
|
|
484
|
+
function looksLikeMessageKey(s) {
|
|
485
|
+
return /^[a-z][a-z0-9]*(\.[a-z0-9_]+)+$/i.test(s);
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Prose a user would read, as opposed to a token, an identifier or a code
|
|
489
|
+
* sample. Deliberately the same judgement the Studio's own i18n gate makes —
|
|
490
|
+
* flagging `CSV` or `status=active` would train authors to ignore the warning.
|
|
491
|
+
*/
|
|
492
|
+
function looksTranslatable(s) {
|
|
493
|
+
const t = s.trim();
|
|
494
|
+
if (t.length < 3)
|
|
495
|
+
return false;
|
|
496
|
+
if (I18N_UNIVERSAL.has(t))
|
|
497
|
+
return false;
|
|
498
|
+
if (isProductNameList(t))
|
|
499
|
+
return false;
|
|
500
|
+
if (!/[a-z]{2}/.test(t))
|
|
501
|
+
return false;
|
|
502
|
+
if (/^[a-z0-9_.\-/]+$/.test(t))
|
|
503
|
+
return false; // identifier or path
|
|
504
|
+
if (/^[a-z0-9_]+=/.test(t))
|
|
505
|
+
return false; // filter/code sample, e.g. status=active
|
|
506
|
+
if (t.startsWith('http'))
|
|
507
|
+
return false;
|
|
508
|
+
return /^[A-Z]/.test(t) || t.split(/\s+/).length > 1;
|
|
509
|
+
}
|
|
510
|
+
/** Recursively collect every user-visible string with the slot it came from. */
|
|
511
|
+
function collectI18nStrings(node, acc) {
|
|
512
|
+
if (Array.isArray(node)) {
|
|
513
|
+
for (const x of node)
|
|
514
|
+
collectI18nStrings(x, acc);
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
if (node && typeof node === 'object') {
|
|
518
|
+
for (const [k, v] of Object.entries(node)) {
|
|
519
|
+
if (SDUI_I18N_SLOTS.includes(k) && typeof v === 'string' && v)
|
|
520
|
+
acc.push({ slot: k, value: v });
|
|
521
|
+
else
|
|
522
|
+
collectI18nStrings(v, acc);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
/** Recursively collect every `dataSource` / `endpoint` / `saveEndpoint` string. */
|
|
527
|
+
function collectEndpoints(node, acc) {
|
|
528
|
+
if (Array.isArray(node)) {
|
|
529
|
+
for (const x of node)
|
|
530
|
+
collectEndpoints(x, acc);
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
if (node && typeof node === 'object') {
|
|
534
|
+
for (const [k, v] of Object.entries(node)) {
|
|
535
|
+
if ((k === 'dataSource' ||
|
|
536
|
+
k === 'endpoint' ||
|
|
537
|
+
k === 'saveEndpoint' ||
|
|
538
|
+
k === 'catalogDataSource' ||
|
|
539
|
+
k === 'loadEndpoint') &&
|
|
540
|
+
typeof v === 'string')
|
|
541
|
+
acc.push(v);
|
|
542
|
+
else
|
|
543
|
+
collectEndpoints(v, acc);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
export function validateSduiSchema(input) {
|
|
548
|
+
const out = [];
|
|
549
|
+
const { schema, extName, file } = input;
|
|
550
|
+
if (schema === null || typeof schema !== 'object' || Array.isArray(schema)) {
|
|
551
|
+
out.push(err('SDUI_NOT_OBJECT', 'schema must be a JSON object', file));
|
|
552
|
+
return out;
|
|
553
|
+
}
|
|
554
|
+
const s = schema;
|
|
555
|
+
// Light structural guard (the host's validateSchema is the full one).
|
|
556
|
+
if (typeof s.title !== 'string' || !s.title)
|
|
557
|
+
out.push(err('SDUI_NO_TITLE', 'schema missing "title"', file));
|
|
558
|
+
if (s.kind === 'settings') {
|
|
559
|
+
if (typeof s.dataSource !== 'string' || typeof s.saveEndpoint !== 'string')
|
|
560
|
+
out.push(err('SDUI_SETTINGS_SHAPE', 'settings schema needs dataSource + saveEndpoint', file));
|
|
561
|
+
}
|
|
562
|
+
else if (!Array.isArray(s.resources) || s.resources.length === 0) {
|
|
563
|
+
out.push(err('SDUI_NO_RESOURCES', 'page schema needs a non-empty "resources" array', file));
|
|
564
|
+
}
|
|
565
|
+
const mount = `/ext/${extName}`;
|
|
566
|
+
const depMounts = (input.dependencies ?? []).map((d) => `/ext/${d}`);
|
|
567
|
+
const provided = input.providedRoutes
|
|
568
|
+
? new Set(input.providedRoutes.map(normalizeRoute))
|
|
569
|
+
: undefined;
|
|
570
|
+
const inMount = (bare, m) => bare === m || bare.startsWith(`${m}/`);
|
|
571
|
+
const endpoints = [];
|
|
572
|
+
collectEndpoints(schema, endpoints);
|
|
573
|
+
for (const raw of endpoints) {
|
|
574
|
+
const bare = raw.split('?')[0];
|
|
575
|
+
if (SDUI_SHARED_READ_ALLOWLIST.has(bare))
|
|
576
|
+
continue;
|
|
577
|
+
// A dependency's namespace is allowed (relation pickers etc.).
|
|
578
|
+
if (depMounts.some((m) => inMount(bare, m)))
|
|
579
|
+
continue;
|
|
580
|
+
if (!inMount(bare, mount)) {
|
|
581
|
+
out.push(err('SDUI_ENDPOINT_FOREIGN', `endpoint "${raw}" is outside this extension's namespace "${mount}/" (or a declared dependency, or the shared-read allowlist). Schemas may only call their own routes.`, file));
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
584
|
+
if (provided) {
|
|
585
|
+
const rel = normalizeRoute(bare.slice(mount.length) || '/');
|
|
586
|
+
if (!isProvidedRoute(provided, rel)) {
|
|
587
|
+
out.push(err('SDUI_ENDPOINT_UNKNOWN', `endpoint "${raw}" resolves to "${rel}", which the engine does not serve. Provided routes: ${[...provided].sort().join(', ') || '(none found)'}.`, file));
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
// ── i18n ─────────────────────────────────────────────────────────────────
|
|
592
|
+
// A schema is data, so every user-visible string sits in a named slot and can
|
|
593
|
+
// be checked exactly — no source parsing, no heuristics about what is prose.
|
|
594
|
+
// That is the property hand-written pages do not have.
|
|
595
|
+
if (input.messageKeys) {
|
|
596
|
+
const known = new Set(input.messageKeys);
|
|
597
|
+
const strings = [];
|
|
598
|
+
collectI18nStrings(schema, strings);
|
|
599
|
+
for (const { slot, value } of strings) {
|
|
600
|
+
if (known.has(value))
|
|
601
|
+
continue;
|
|
602
|
+
if (looksLikeMessageKey(value)) {
|
|
603
|
+
out.push(err('SDUI_I18N_KEY_MISSING', `${slot} "${value}" is not a known message key. The host renders an unknown key as literal text, so this reaches the user as-is. Add it to studio/messages/{locale}.json, or use a shared common.* key.`, file));
|
|
604
|
+
}
|
|
605
|
+
else if (looksTranslatable(value)) {
|
|
606
|
+
out.push(warn('SDUI_I18N_HARDCODED', `${slot} "${value}" is hardcoded text, not a message key — it stays English in all nine locales. Move it to studio/messages/{locale}.json.`, file));
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
return out;
|
|
611
|
+
}
|
|
612
|
+
/** Join a Hono mount prefix with a child path (`/query` + `/history` → `/query/history`). */
|
|
613
|
+
function joinRoutePath(prefix, child) {
|
|
614
|
+
if (!child || child === '/')
|
|
615
|
+
return prefix || '/';
|
|
616
|
+
const p = prefix.endsWith('/') ? prefix.slice(0, -1) : prefix;
|
|
617
|
+
return child.startsWith('/') ? `${p}${child}` : `${p}/${child}`;
|
|
618
|
+
}
|
|
619
|
+
/** True when `rel` is listed, or falls under a `.route('/prefix')` mount recorded as `/prefix/*`. */
|
|
620
|
+
function isProvidedRoute(provided, rel) {
|
|
621
|
+
if (provided.has(rel))
|
|
622
|
+
return true;
|
|
623
|
+
for (const p of provided) {
|
|
624
|
+
if (!p.endsWith('/*'))
|
|
625
|
+
continue;
|
|
626
|
+
const base = p.slice(0, -2);
|
|
627
|
+
// Never treat a root `/*` as "matches everything" — only explicit mounts.
|
|
628
|
+
if (!base || base === '/')
|
|
629
|
+
continue;
|
|
630
|
+
if (rel === base || rel.startsWith(`${base}/`))
|
|
631
|
+
return true;
|
|
632
|
+
}
|
|
633
|
+
return false;
|
|
634
|
+
}
|
|
635
|
+
/** Extract route paths an extension serves from its engine source text.
|
|
636
|
+
*
|
|
637
|
+
* Heuristic over `.get/post/put/patch/delete('…')` and `.route('/prefix', …)`
|
|
638
|
+
* mounts. Nested Hono sub-apps (e.g. `app.route('/query', aiQueryRoutes)`) used
|
|
639
|
+
* to be invisible here, so schemas pointing at the real `/query/history` path
|
|
640
|
+
* failed `SDUI_ENDPOINT_UNKNOWN` while a wrong bare `/history` falsely passed.
|
|
641
|
+
*
|
|
642
|
+
* Mounts register `/prefix` and `/prefix/*`; matching allows any path under that
|
|
643
|
+
* prefix (see `isProvidedRoute`). Parsing each factory body is intentionally
|
|
644
|
+
* avoided — route handlers often contain regexes/`${…}` that defeat brace
|
|
645
|
+
* matching. */
|
|
646
|
+
export function extractEngineRoutes(engineSourceTexts) {
|
|
647
|
+
const routes = new Set();
|
|
648
|
+
const methodRe = /\.(?:get|post|put|patch|delete)\(\s*['"`]([^'"`]+)['"`]/g;
|
|
649
|
+
const mountRe = /\.route\(\s*['"`]([^'"`]+)['"`]/g;
|
|
650
|
+
for (const text of engineSourceTexts) {
|
|
651
|
+
let m;
|
|
652
|
+
// biome-ignore lint/suspicious/noAssignInExpressions: standard regex loop
|
|
653
|
+
while ((m = methodRe.exec(text)) !== null) {
|
|
654
|
+
if (m[1].startsWith('/'))
|
|
655
|
+
routes.add(m[1]);
|
|
656
|
+
}
|
|
657
|
+
// biome-ignore lint/suspicious/noAssignInExpressions: standard regex loop
|
|
658
|
+
while ((m = mountRe.exec(text)) !== null) {
|
|
659
|
+
const prefix = m[1];
|
|
660
|
+
if (!prefix.startsWith('/'))
|
|
661
|
+
continue;
|
|
662
|
+
routes.add(prefix);
|
|
663
|
+
// Root mounts (`app.route('/', sub)`) do not get a blanket `/*`.
|
|
664
|
+
if (prefix !== '/')
|
|
665
|
+
routes.add(joinRoutePath(prefix, '/*'));
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
return [...routes];
|
|
669
|
+
}
|
|
184
670
|
export function validateExtension(input) {
|
|
185
671
|
const errors = [
|
|
186
672
|
...validateManifest(input.manifest),
|
|
@@ -190,7 +676,8 @@ export function validateExtension(input) {
|
|
|
190
676
|
...(input.bundleSize ? validateBundleSize(input.bundleSize) : []),
|
|
191
677
|
];
|
|
192
678
|
return {
|
|
193
|
-
|
|
679
|
+
// Warnings are surfaced in `errors` but do not fail validation.
|
|
680
|
+
ok: errors.every((e) => e.severity === 'warning'),
|
|
194
681
|
errors,
|
|
195
682
|
stats: {
|
|
196
683
|
tables: input.stats.tables,
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GENERATED — do not edit. Run `bun run scripts/sync-shared-message-keys.ts`.
|
|
3
|
+
*
|
|
4
|
+
* The subset of the Studio's core message catalogue that extensions may use
|
|
5
|
+
* in SDUI schemas without shipping the key themselves: the generic vocabulary
|
|
6
|
+
* (`common.*`) plus the shared extension helpers (`ext.*`).
|
|
7
|
+
*
|
|
8
|
+
* An extension that needs anything else must ship it in its own
|
|
9
|
+
* `studio/messages/{locale}.json` — otherwise it renders correctly only on a
|
|
10
|
+
* host whose core catalogue happens to know about it, which is not a property
|
|
11
|
+
* an installable extension should depend on.
|
|
12
|
+
*
|
|
13
|
+
* Source: packages/studio/messages/core/en.json (211 keys)
|
|
14
|
+
*/
|
|
15
|
+
export declare const SHARED_MESSAGE_KEYS: ReadonlySet<string>;
|
|
16
|
+
//# sourceMappingURL=shared-message-keys.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"shared-message-keys.d.ts","sourceRoot":"","sources":["../../src/validate/shared-message-keys.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,mBAAmB,EAAE,WAAW,CAAC,MAAM,CAoNlD,CAAC"}
|