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.
@@ -1,2 +1,2 @@
1
- export declare const CLI_VERSION = "0.1.758";
1
+ export declare const CLI_VERSION = "0.1.759";
2
2
  //# sourceMappingURL=version.d.ts.map
@@ -1,3 +1,3 @@
1
1
  // Auto-generated by prebuild — do not edit manually
2
- export const CLI_VERSION = "0.1.758";
2
+ export const CLI_VERSION = "0.1.759";
3
3
  //# sourceMappingURL=version.js.map
@@ -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;;;;;;;;;;;;;;;GAeG;AACH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AAIhD,4EAA4E;AAC5E,wBAAgB,oBAAoB,CAClC,UAAU,EAAE,MAAM,EAClB,OAAO,CAAC,EAAE,QAAQ,EAAE,GACnB,MAAM,EAAE,CAmBV"}
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 fields = Object.keys(idx.fields);
9
- // Single-field expression indexes only — matches the query layer, which
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
- const field = fields[0];
14
- if (!VALID_NAME.test(field))
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
- const name = idx.unique
18
- ? `idx_${collection}_${field}_unique`
19
- : `idx_${collection}_${field}`;
20
- out.push(`CREATE ${unique}INDEX IF NOT EXISTS "${name}" ON "${collection}" ((data->>'${field}'))`);
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":"AAkBA,MAAM,UAAU,GAAG,0BAA0B,CAAC;AAE9C,4EAA4E;AAC5E,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,MAAM,GAAG,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;QACvC,wEAAwE;QACxE,yEAAyE;QACzE,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC;YAAE,SAAS;QAClC,MAAM,KAAK,GAAG,MAAM,CAAC,CAAC,CAAC,CAAC;QACxB,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,KAAK,CAAC;YAAE,SAAS;QACtC,MAAM,MAAM,GAAG,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC;QAC3C,MAAM,IAAI,GAAG,GAAG,CAAC,MAAM;YACrB,CAAC,CAAC,OAAO,UAAU,IAAI,KAAK,SAAS;YACrC,CAAC,CAAC,OAAO,UAAU,IAAI,KAAK,EAAE,CAAC;QACjC,GAAG,CAAC,IAAI,CACN,UAAU,MAAM,wBAAwB,IAAI,SAAS,UAAU,eAAe,KAAK,KAAK,CACzF,CAAC;IACJ,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC"}
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.758",
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": [
@@ -1,2 +1,2 @@
1
1
  // Auto-generated by prebuild — do not edit manually
2
- export const CLI_VERSION = "0.1.758";
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('skips composite (multi-field) indexes single-field only', () => {
32
- expect(fieldIndexStatements('x', [{ fields: { a: 1, b: 1 } }])).toEqual([]);
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 fields = Object.keys(idx.fields);
30
- // Single-field expression indexes only — matches the query layer, which
31
- // filters one JSONB field at a time. (Composite indexes aren't emitted.)
32
- if (fields.length !== 1) continue;
33
- const field = fields[0];
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
- const name = idx.unique
37
- ? `idx_${collection}_${field}_unique`
38
- : `idx_${collection}_${field}`;
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}" ((data->>'${field}'))`,
55
+ `CREATE ${unique}INDEX IF NOT EXISTS "${name}" ON "${collection}" (${cols})`,
41
56
  );
42
57
  }
43
58
  return out;