ugly-app 0.1.758 → 0.1.759
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/cli/version.d.ts +1 -1
- package/dist/cli/version.js +1 -1
- package/dist/server/schemaIndexes.d.ts +12 -2
- package/dist/server/schemaIndexes.d.ts.map +1 -1
- package/dist/server/schemaIndexes.js +16 -11
- package/dist/server/schemaIndexes.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/version.ts +1 -1
- package/src/server/schemaIndexes.test.ts +31 -2
- package/src/server/schemaIndexes.ts +27 -12
package/dist/cli/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const CLI_VERSION = "0.1.
|
|
1
|
+
export declare const CLI_VERSION = "0.1.759";
|
|
2
2
|
//# sourceMappingURL=version.d.ts.map
|
package/dist/cli/version.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Single source of truth for the btree expression indexes a collection declares
|
|
3
|
-
* via `indexes: [{ fields: { <field>: 1 } }]`.
|
|
3
|
+
* via `indexes: [{ fields: { <field>: 1 | -1, ... } }]`.
|
|
4
4
|
*
|
|
5
5
|
* The default `idx_<c>_data` GIN index covers JSONB containment (`data @> …`),
|
|
6
6
|
* but it CANNOT serve the scalar lookups the query layer actually emits
|
|
@@ -9,12 +9,22 @@
|
|
|
9
9
|
* sequential-scans — invisible on a small table, a hang once it grows past the
|
|
10
10
|
* 30s statement_timeout.
|
|
11
11
|
*
|
|
12
|
+
* Single-field AND composite (multi-field) indexes are emitted. A composite
|
|
13
|
+
* `{ fields: { a: 1, b: -1 } }` produces ONE btree over
|
|
14
|
+
* `((data->>'a'), (data->>'b') DESC)` — which serves a multi-field equality
|
|
15
|
+
* filter AND a matching `ORDER BY` in a single index seek. (A bitmap-AND of
|
|
16
|
+
* single-field indexes can satisfy the filter, but never the ordering.) Field
|
|
17
|
+
* order and per-field direction (1 = ASC, -1 = DESC) are preserved from the
|
|
18
|
+
* declaration; the `PostgresIndexes` coverage checker already credits every
|
|
19
|
+
* field of a composite expression index, so declaring one silences the
|
|
20
|
+
* per-field unindexed warning for all of its fields.
|
|
21
|
+
*
|
|
12
22
|
* Pure string-builder (no pg/pool imports) so it's safe to share across the Node
|
|
13
23
|
* schema sync (`PostgresSchema.ensureTable`), the Workers `/_init` route, and the
|
|
14
24
|
* migration generator (`schemaGen`). Statements are idempotent (IF NOT EXISTS) so
|
|
15
25
|
* every call site can run them on every deploy.
|
|
16
26
|
*/
|
|
17
27
|
import type { IndexDef } from '../shared/DB.js';
|
|
18
|
-
/** Raw `CREATE [UNIQUE] INDEX` SQL for each declared single-field index. */
|
|
28
|
+
/** Raw `CREATE [UNIQUE] INDEX` SQL for each declared (single- or multi-field) index. */
|
|
19
29
|
export declare function fieldIndexStatements(collection: string, indexes?: IndexDef[]): string[];
|
|
20
30
|
//# sourceMappingURL=schemaIndexes.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"schemaIndexes.d.ts","sourceRoot":"","sources":["../../src/server/schemaIndexes.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"schemaIndexes.d.ts","sourceRoot":"","sources":["../../src/server/schemaIndexes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AAIhD,wFAAwF;AACxF,wBAAgB,oBAAoB,CAClC,UAAU,EAAE,MAAM,EAClB,OAAO,CAAC,EAAE,QAAQ,EAAE,GACnB,MAAM,EAAE,CAwBV"}
|
|
@@ -1,23 +1,28 @@
|
|
|
1
1
|
const VALID_NAME = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
2
|
-
/** Raw `CREATE [UNIQUE] INDEX` SQL for each declared single-field index. */
|
|
2
|
+
/** Raw `CREATE [UNIQUE] INDEX` SQL for each declared (single- or multi-field) index. */
|
|
3
3
|
export function fieldIndexStatements(collection, indexes) {
|
|
4
4
|
if (!VALID_NAME.test(collection))
|
|
5
5
|
return [];
|
|
6
6
|
const out = [];
|
|
7
7
|
for (const idx of indexes ?? []) {
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
// filters one JSONB field at a time. (Composite indexes aren't emitted.)
|
|
11
|
-
if (fields.length !== 1)
|
|
8
|
+
const entries = Object.entries(idx.fields);
|
|
9
|
+
if (entries.length === 0)
|
|
12
10
|
continue;
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
// Reject the WHOLE index if any field name is unsafe — the names are
|
|
12
|
+
// interpolated straight into DDL, so there must be no injection surface.
|
|
13
|
+
if (!entries.every(([field]) => VALID_NAME.test(field)))
|
|
15
14
|
continue;
|
|
16
15
|
const unique = idx.unique ? 'UNIQUE ' : '';
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
16
|
+
// `idx_<collection>_<f1>_<f2>_…` (+ `_unique`). Field order is significant —
|
|
17
|
+
// it matches the composite btree's leading-column order.
|
|
18
|
+
const base = `idx_${collection}_${entries.map(([field]) => field).join('_')}`;
|
|
19
|
+
const name = idx.unique ? `${base}_unique` : base;
|
|
20
|
+
// Each field is its own parenthesised expression; `-1` appends DESC so a
|
|
21
|
+
// filter+ORDER BY read is served entirely by the index.
|
|
22
|
+
const cols = entries
|
|
23
|
+
.map(([field, dir]) => `(data->>'${field}')${dir === -1 ? ' DESC' : ''}`)
|
|
24
|
+
.join(', ');
|
|
25
|
+
out.push(`CREATE ${unique}INDEX IF NOT EXISTS "${name}" ON "${collection}" (${cols})`);
|
|
21
26
|
}
|
|
22
27
|
return out;
|
|
23
28
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"schemaIndexes.js","sourceRoot":"","sources":["../../src/server/schemaIndexes.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"schemaIndexes.js","sourceRoot":"","sources":["../../src/server/schemaIndexes.ts"],"names":[],"mappings":"AA4BA,MAAM,UAAU,GAAG,0BAA0B,CAAC;AAE9C,wFAAwF;AACxF,MAAM,UAAU,oBAAoB,CAClC,UAAkB,EAClB,OAAoB;IAEpB,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,UAAU,CAAC;QAAE,OAAO,EAAE,CAAC;IAC5C,MAAM,GAAG,GAAa,EAAE,CAAC;IACzB,KAAK,MAAM,GAAG,IAAI,OAAO,IAAI,EAAE,EAAE,CAAC;QAChC,MAAM,OAAO,GAAG,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;QAC3C,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC;YAAE,SAAS;QACnC,qEAAqE;QACrE,yEAAyE;QACzE,IAAI,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,EAAE,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;YAAE,SAAS;QAClE,MAAM,MAAM,GAAG,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC;QAC3C,6EAA6E;QAC7E,yDAAyD;QACzD,MAAM,IAAI,GAAG,OAAO,UAAU,IAAI,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,EAAE,EAAE,CAAC,KAAK,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC;QAC9E,MAAM,IAAI,GAAG,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,GAAG,IAAI,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC;QAClD,yEAAyE;QACzE,wDAAwD;QACxD,MAAM,IAAI,GAAG,OAAO;aACjB,GAAG,CAAC,CAAC,CAAC,KAAK,EAAE,GAAG,CAAC,EAAE,EAAE,CAAC,YAAY,KAAK,KAAK,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC;aACxE,IAAI,CAAC,IAAI,CAAC,CAAC;QACd,GAAG,CAAC,IAAI,CACN,UAAU,MAAM,wBAAwB,IAAI,SAAS,UAAU,MAAM,IAAI,GAAG,CAC7E,CAAC;IACJ,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ugly-app",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.759",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"comment:files": "Allowlist what ships to npm. dist = runtime; src = sourcemap targets (dist/*.js.map reference ../../src/); templates = CLI scaffold. Everything else at repo root (.pgdata local Postgres, coverage, assets/icons sources, test/, test-results/) is excluded by omission. The !negations strip the scaffold's installed deps + cruft (templates/node_modules is 200MB+ and must never ship). package.json/README/LICENSE ship automatically.",
|
|
6
6
|
"files": [
|
package/src/cli/version.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Auto-generated by prebuild — do not edit manually
|
|
2
|
-
export const CLI_VERSION = "0.1.
|
|
2
|
+
export const CLI_VERSION = "0.1.759";
|
|
@@ -28,17 +28,46 @@ describe('fieldIndexStatements', () => {
|
|
|
28
28
|
expect(sql[1]).toContain(`((data->>'provider'))`);
|
|
29
29
|
});
|
|
30
30
|
|
|
31
|
-
it('
|
|
32
|
-
|
|
31
|
+
it('emits ONE composite btree over all fields (order preserved)', () => {
|
|
32
|
+
const sql = fieldIndexStatements('codingSession', [
|
|
33
|
+
{ fields: { userId: 1, projectId: 1, kind: 1 } },
|
|
34
|
+
]);
|
|
35
|
+
expect(sql).toEqual([
|
|
36
|
+
`CREATE INDEX IF NOT EXISTS "idx_codingSession_userId_projectId_kind" ` +
|
|
37
|
+
`ON "codingSession" ((data->>'userId'), (data->>'projectId'), (data->>'kind'))`,
|
|
38
|
+
]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('appends DESC for a -1 field so filter+ORDER BY is index-served', () => {
|
|
42
|
+
const sql = fieldIndexStatements('codingSession', [
|
|
43
|
+
{ fields: { userId: 1, updated: -1 } },
|
|
44
|
+
]);
|
|
45
|
+
expect(sql).toEqual([
|
|
46
|
+
`CREATE INDEX IF NOT EXISTS "idx_codingSession_userId_updated" ` +
|
|
47
|
+
`ON "codingSession" ((data->>'userId'), (data->>'updated') DESC)`,
|
|
48
|
+
]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('emits a UNIQUE composite index (with _unique suffix)', () => {
|
|
52
|
+
const sql = fieldIndexStatements('member', [
|
|
53
|
+
{ fields: { orgId: 1, userId: 1 }, unique: true },
|
|
54
|
+
]);
|
|
55
|
+
expect(sql).toEqual([
|
|
56
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS "idx_member_orgId_userId_unique" ` +
|
|
57
|
+
`ON "member" ((data->>'orgId'), (data->>'userId'))`,
|
|
58
|
+
]);
|
|
33
59
|
});
|
|
34
60
|
|
|
35
61
|
it('returns [] for no/empty indexes', () => {
|
|
36
62
|
expect(fieldIndexStatements('x')).toEqual([]);
|
|
37
63
|
expect(fieldIndexStatements('x', [])).toEqual([]);
|
|
64
|
+
expect(fieldIndexStatements('x', [{ fields: {} }])).toEqual([]);
|
|
38
65
|
});
|
|
39
66
|
|
|
40
67
|
it('refuses unsafe collection/field names (no SQL injection surface)', () => {
|
|
41
68
|
expect(fieldIndexStatements('bad-name', [{ fields: { userId: 1 } }])).toEqual([]);
|
|
42
69
|
expect(fieldIndexStatements('ok', [{ fields: { 'a; DROP TABLE x': 1 } }])).toEqual([]);
|
|
70
|
+
// one bad field poisons the whole composite index (no partial emit)
|
|
71
|
+
expect(fieldIndexStatements('ok', [{ fields: { userId: 1, 'a; DROP': 1 } }])).toEqual([]);
|
|
43
72
|
});
|
|
44
73
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Single source of truth for the btree expression indexes a collection declares
|
|
3
|
-
* via `indexes: [{ fields: { <field>: 1 } }]`.
|
|
3
|
+
* via `indexes: [{ fields: { <field>: 1 | -1, ... } }]`.
|
|
4
4
|
*
|
|
5
5
|
* The default `idx_<c>_data` GIN index covers JSONB containment (`data @> …`),
|
|
6
6
|
* but it CANNOT serve the scalar lookups the query layer actually emits
|
|
@@ -9,6 +9,16 @@
|
|
|
9
9
|
* sequential-scans — invisible on a small table, a hang once it grows past the
|
|
10
10
|
* 30s statement_timeout.
|
|
11
11
|
*
|
|
12
|
+
* Single-field AND composite (multi-field) indexes are emitted. A composite
|
|
13
|
+
* `{ fields: { a: 1, b: -1 } }` produces ONE btree over
|
|
14
|
+
* `((data->>'a'), (data->>'b') DESC)` — which serves a multi-field equality
|
|
15
|
+
* filter AND a matching `ORDER BY` in a single index seek. (A bitmap-AND of
|
|
16
|
+
* single-field indexes can satisfy the filter, but never the ordering.) Field
|
|
17
|
+
* order and per-field direction (1 = ASC, -1 = DESC) are preserved from the
|
|
18
|
+
* declaration; the `PostgresIndexes` coverage checker already credits every
|
|
19
|
+
* field of a composite expression index, so declaring one silences the
|
|
20
|
+
* per-field unindexed warning for all of its fields.
|
|
21
|
+
*
|
|
12
22
|
* Pure string-builder (no pg/pool imports) so it's safe to share across the Node
|
|
13
23
|
* schema sync (`PostgresSchema.ensureTable`), the Workers `/_init` route, and the
|
|
14
24
|
* migration generator (`schemaGen`). Statements are idempotent (IF NOT EXISTS) so
|
|
@@ -18,7 +28,7 @@ import type { IndexDef } from '../shared/DB.js';
|
|
|
18
28
|
|
|
19
29
|
const VALID_NAME = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
20
30
|
|
|
21
|
-
/** Raw `CREATE [UNIQUE] INDEX` SQL for each declared single-field index. */
|
|
31
|
+
/** Raw `CREATE [UNIQUE] INDEX` SQL for each declared (single- or multi-field) index. */
|
|
22
32
|
export function fieldIndexStatements(
|
|
23
33
|
collection: string,
|
|
24
34
|
indexes?: IndexDef[],
|
|
@@ -26,18 +36,23 @@ export function fieldIndexStatements(
|
|
|
26
36
|
if (!VALID_NAME.test(collection)) return [];
|
|
27
37
|
const out: string[] = [];
|
|
28
38
|
for (const idx of indexes ?? []) {
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
if (!VALID_NAME.test(field)) continue;
|
|
39
|
+
const entries = Object.entries(idx.fields);
|
|
40
|
+
if (entries.length === 0) continue;
|
|
41
|
+
// Reject the WHOLE index if any field name is unsafe — the names are
|
|
42
|
+
// interpolated straight into DDL, so there must be no injection surface.
|
|
43
|
+
if (!entries.every(([field]) => VALID_NAME.test(field))) continue;
|
|
35
44
|
const unique = idx.unique ? 'UNIQUE ' : '';
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
45
|
+
// `idx_<collection>_<f1>_<f2>_…` (+ `_unique`). Field order is significant —
|
|
46
|
+
// it matches the composite btree's leading-column order.
|
|
47
|
+
const base = `idx_${collection}_${entries.map(([field]) => field).join('_')}`;
|
|
48
|
+
const name = idx.unique ? `${base}_unique` : base;
|
|
49
|
+
// Each field is its own parenthesised expression; `-1` appends DESC so a
|
|
50
|
+
// filter+ORDER BY read is served entirely by the index.
|
|
51
|
+
const cols = entries
|
|
52
|
+
.map(([field, dir]) => `(data->>'${field}')${dir === -1 ? ' DESC' : ''}`)
|
|
53
|
+
.join(', ');
|
|
39
54
|
out.push(
|
|
40
|
-
`CREATE ${unique}INDEX IF NOT EXISTS "${name}" ON "${collection}" (
|
|
55
|
+
`CREATE ${unique}INDEX IF NOT EXISTS "${name}" ON "${collection}" (${cols})`,
|
|
41
56
|
);
|
|
42
57
|
}
|
|
43
58
|
return out;
|