@fuzdev/fuz_app 0.110.1 → 0.112.0

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.
Files changed (210) hide show
  1. package/dist/actions/CLAUDE.md +15 -11
  2. package/dist/actions/action_rpc.d.ts +3 -3
  3. package/dist/actions/action_rpc.d.ts.map +1 -1
  4. package/dist/actions/action_rpc.js +4 -6
  5. package/dist/actions/peer_ping.d.ts +8 -0
  6. package/dist/actions/peer_ping.d.ts.map +1 -1
  7. package/dist/actions/peer_ping.js +8 -0
  8. package/dist/actions/peer_request.d.ts +1 -1
  9. package/dist/actions/peer_request.js +1 -1
  10. package/dist/actions/perform_action.d.ts +16 -9
  11. package/dist/actions/perform_action.d.ts.map +1 -1
  12. package/dist/actions/perform_action.js +30 -21
  13. package/dist/actions/register_action_ws.d.ts +1 -1
  14. package/dist/actions/register_action_ws.d.ts.map +1 -1
  15. package/dist/actions/register_action_ws.js +88 -21
  16. package/dist/auth/CLAUDE.md +59 -10
  17. package/dist/auth/account_action_specs.d.ts +0 -2
  18. package/dist/auth/account_action_specs.d.ts.map +1 -1
  19. package/dist/auth/account_actions.d.ts.map +1 -1
  20. package/dist/auth/account_actions.js +10 -1
  21. package/dist/auth/account_queries.d.ts +22 -0
  22. package/dist/auth/account_queries.d.ts.map +1 -1
  23. package/dist/auth/account_queries.js +70 -16
  24. package/dist/auth/account_schema.d.ts +27 -9
  25. package/dist/auth/account_schema.d.ts.map +1 -1
  26. package/dist/auth/account_schema.js +23 -3
  27. package/dist/auth/actor_search_actions.d.ts +3 -3
  28. package/dist/auth/actor_search_actions.d.ts.map +1 -1
  29. package/dist/auth/actor_search_actions.js +10 -6
  30. package/dist/auth/admin_action_specs.d.ts +0 -2
  31. package/dist/auth/admin_action_specs.d.ts.map +1 -1
  32. package/dist/auth/api_token_queries.d.ts +9 -4
  33. package/dist/auth/api_token_queries.d.ts.map +1 -1
  34. package/dist/auth/api_token_queries.js +36 -8
  35. package/dist/auth/app_settings_queries.d.ts +8 -0
  36. package/dist/auth/app_settings_queries.d.ts.map +1 -1
  37. package/dist/auth/app_settings_queries.js +16 -3
  38. package/dist/auth/audit_log_queries.d.ts +21 -3
  39. package/dist/auth/audit_log_queries.d.ts.map +1 -1
  40. package/dist/auth/audit_log_queries.js +38 -7
  41. package/dist/auth/audit_log_schema.d.ts +0 -1
  42. package/dist/auth/audit_log_schema.d.ts.map +1 -1
  43. package/dist/auth/auth_ddl.d.ts +6 -0
  44. package/dist/auth/auth_ddl.d.ts.map +1 -1
  45. package/dist/auth/auth_ddl.js +9 -0
  46. package/dist/auth/cell_actions.d.ts.map +1 -1
  47. package/dist/auth/cell_actions.js +3 -8
  48. package/dist/auth/cell_audit_actions.d.ts.map +1 -1
  49. package/dist/auth/cell_audit_actions.js +1 -1
  50. package/dist/auth/cell_field_actions.js +1 -1
  51. package/dist/auth/cell_grant_actions.js +1 -1
  52. package/dist/auth/cell_item_actions.js +1 -1
  53. package/dist/auth/invite_queries.d.ts +10 -0
  54. package/dist/auth/invite_queries.d.ts.map +1 -1
  55. package/dist/auth/invite_queries.js +28 -7
  56. package/dist/auth/migrations.d.ts.map +1 -1
  57. package/dist/auth/migrations.js +10 -0
  58. package/dist/auth/role_grant_offer_ddl.d.ts +34 -2
  59. package/dist/auth/role_grant_offer_ddl.d.ts.map +1 -1
  60. package/dist/auth/role_grant_offer_ddl.js +61 -2
  61. package/dist/auth/role_grant_offer_queries.d.ts.map +1 -1
  62. package/dist/auth/role_grant_offer_queries.js +48 -53
  63. package/dist/auth/role_grant_queries.d.ts +18 -0
  64. package/dist/auth/role_grant_queries.d.ts.map +1 -1
  65. package/dist/auth/role_grant_queries.js +58 -18
  66. package/dist/auth/session_queries.d.ts +12 -3
  67. package/dist/auth/session_queries.d.ts.map +1 -1
  68. package/dist/auth/session_queries.js +26 -6
  69. package/dist/db/CLAUDE.md +34 -0
  70. package/dist/db/cell_audit_queries.d.ts.map +1 -1
  71. package/dist/db/cell_audit_queries.js +3 -1
  72. package/dist/db/cell_field_queries.d.ts +12 -3
  73. package/dist/db/cell_field_queries.d.ts.map +1 -1
  74. package/dist/db/cell_field_queries.js +25 -7
  75. package/dist/db/cell_grant_queries.d.ts +10 -2
  76. package/dist/db/cell_grant_queries.d.ts.map +1 -1
  77. package/dist/db/cell_grant_queries.js +29 -11
  78. package/dist/db/cell_item_queries.d.ts +13 -2
  79. package/dist/db/cell_item_queries.d.ts.map +1 -1
  80. package/dist/db/cell_item_queries.js +27 -7
  81. package/dist/db/cell_queries.d.ts +32 -3
  82. package/dist/db/cell_queries.d.ts.map +1 -1
  83. package/dist/db/cell_queries.js +57 -22
  84. package/dist/db/fact_queries.d.ts +7 -0
  85. package/dist/db/fact_queries.d.ts.map +1 -1
  86. package/dist/db/fact_queries.js +27 -4
  87. package/dist/db/schema_ready.d.ts +6 -1
  88. package/dist/db/schema_ready.d.ts.map +1 -1
  89. package/dist/db/schema_ready.js +6 -2
  90. package/dist/db/sql_columns.d.ts +144 -0
  91. package/dist/db/sql_columns.d.ts.map +1 -0
  92. package/dist/db/sql_columns.js +164 -0
  93. package/dist/db/sql_identifier.d.ts +6 -5
  94. package/dist/db/sql_identifier.d.ts.map +1 -1
  95. package/dist/db/sql_identifier.js +6 -5
  96. package/dist/http/CLAUDE.md +4 -2
  97. package/dist/http/jsonrpc_helpers.d.ts +13 -0
  98. package/dist/http/jsonrpc_helpers.d.ts.map +1 -1
  99. package/dist/http/jsonrpc_helpers.js +13 -0
  100. package/dist/http/route_spec.d.ts +2 -2
  101. package/dist/server/app_server.d.ts.map +1 -1
  102. package/dist/server/app_server.js +1 -2
  103. package/dist/server/serve_fact_route.d.ts.map +1 -1
  104. package/dist/server/serve_fact_route.js +7 -2
  105. package/dist/testing/CLAUDE.md +55 -28
  106. package/dist/testing/admin_integration.d.ts +0 -3
  107. package/dist/testing/admin_integration.d.ts.map +1 -1
  108. package/dist/testing/admin_integration.js +0 -1
  109. package/dist/testing/adversarial_404.d.ts.map +1 -1
  110. package/dist/testing/adversarial_404.js +4 -1
  111. package/dist/testing/adversarial_input.d.ts.map +1 -1
  112. package/dist/testing/adversarial_input.js +5 -4
  113. package/dist/testing/app_server.d.ts +4 -3
  114. package/dist/testing/app_server.d.ts.map +1 -1
  115. package/dist/testing/app_server.js +11 -5
  116. package/dist/testing/attack_surface.d.ts +16 -0
  117. package/dist/testing/attack_surface.d.ts.map +1 -1
  118. package/dist/testing/attack_surface.js +36 -13
  119. package/dist/testing/audit_completeness.d.ts +0 -3
  120. package/dist/testing/audit_completeness.d.ts.map +1 -1
  121. package/dist/testing/audit_completeness.js +0 -1
  122. package/dist/testing/auth_apps.d.ts +9 -2
  123. package/dist/testing/auth_apps.d.ts.map +1 -1
  124. package/dist/testing/auth_apps.js +9 -2
  125. package/dist/testing/cross_backend/account_lifecycle.d.ts +5 -4
  126. package/dist/testing/cross_backend/account_lifecycle.d.ts.map +1 -1
  127. package/dist/testing/cross_backend/account_lifecycle.js +7 -0
  128. package/dist/testing/cross_backend/action_manifest.d.ts +19 -3
  129. package/dist/testing/cross_backend/action_manifest.d.ts.map +1 -1
  130. package/dist/testing/cross_backend/action_manifest.js +10 -4
  131. package/dist/testing/cross_backend/action_manifest_parity.d.ts +9 -3
  132. package/dist/testing/cross_backend/action_manifest_parity.d.ts.map +1 -1
  133. package/dist/testing/cross_backend/action_manifest_parity.js +6 -0
  134. package/dist/testing/cross_backend/actor_lookup.d.ts +3 -2
  135. package/dist/testing/cross_backend/actor_lookup.d.ts.map +1 -1
  136. package/dist/testing/cross_backend/body_size.d.ts +4 -2
  137. package/dist/testing/cross_backend/body_size.d.ts.map +1 -1
  138. package/dist/testing/cross_backend/body_size.js +1 -1
  139. package/dist/testing/cross_backend/cell_crud.d.ts +2 -2
  140. package/dist/testing/cross_backend/cell_crud.d.ts.map +1 -1
  141. package/dist/testing/cross_backend/cell_crud.js +10 -0
  142. package/dist/testing/cross_backend/cell_gated_create.d.ts +3 -3
  143. package/dist/testing/cross_backend/cell_gated_create.d.ts.map +1 -1
  144. package/dist/testing/cross_backend/cell_grant_role.d.ts +2 -2
  145. package/dist/testing/cross_backend/cell_grant_role.d.ts.map +1 -1
  146. package/dist/testing/cross_backend/cell_relations.d.ts +2 -2
  147. package/dist/testing/cross_backend/cell_relations.d.ts.map +1 -1
  148. package/dist/testing/cross_backend/expected_schema.json +1 -1
  149. package/dist/testing/cross_backend/fact_serving.d.ts +2 -2
  150. package/dist/testing/cross_backend/fact_serving.d.ts.map +1 -1
  151. package/dist/testing/cross_backend/make_cross_backend_project.d.ts +17 -2
  152. package/dist/testing/cross_backend/make_cross_backend_project.d.ts.map +1 -1
  153. package/dist/testing/cross_backend/make_cross_backend_project.js +23 -3
  154. package/dist/testing/cross_backend/origin.d.ts +4 -2
  155. package/dist/testing/cross_backend/origin.d.ts.map +1 -1
  156. package/dist/testing/cross_backend/origin.js +1 -1
  157. package/dist/testing/cross_backend/peer_ping_ws.d.ts.map +1 -1
  158. package/dist/testing/cross_backend/peer_ping_ws.js +11 -7
  159. package/dist/testing/cross_backend/query_shape.d.ts +6 -5
  160. package/dist/testing/cross_backend/query_shape.d.ts.map +1 -1
  161. package/dist/testing/cross_backend/ready.d.ts +2 -2
  162. package/dist/testing/cross_backend/ready.d.ts.map +1 -1
  163. package/dist/testing/cross_backend/role_grant_offer_enumeration.d.ts.map +1 -1
  164. package/dist/testing/cross_backend/role_grant_offer_enumeration.js +7 -1
  165. package/dist/testing/cross_backend/setup.d.ts +26 -6
  166. package/dist/testing/cross_backend/setup.d.ts.map +1 -1
  167. package/dist/testing/cross_backend/standard.d.ts +5 -1
  168. package/dist/testing/cross_backend/standard.d.ts.map +1 -1
  169. package/dist/testing/cross_backend/standard.js +1 -5
  170. package/dist/testing/cross_backend/testing_backdoor.js +1 -1
  171. package/dist/testing/cross_backend/testing_reset_actions.d.ts +5 -0
  172. package/dist/testing/cross_backend/testing_reset_actions.d.ts.map +1 -1
  173. package/dist/testing/cross_backend/token_lifetime.d.ts.map +1 -1
  174. package/dist/testing/cross_backend/token_lifetime.js +6 -0
  175. package/dist/testing/cross_backend/token_scope_surface.d.ts +2 -2
  176. package/dist/testing/cross_backend/token_scope_surface.d.ts.map +1 -1
  177. package/dist/testing/cross_backend/wire_shapes.d.ts +22 -0
  178. package/dist/testing/cross_backend/wire_shapes.d.ts.map +1 -0
  179. package/dist/testing/cross_backend/wire_shapes.js +41 -0
  180. package/dist/testing/data_exposure.d.ts +0 -3
  181. package/dist/testing/data_exposure.d.ts.map +1 -1
  182. package/dist/testing/data_exposure.js +0 -1
  183. package/dist/testing/db.d.ts +62 -7
  184. package/dist/testing/db.d.ts.map +1 -1
  185. package/dist/testing/db.js +86 -28
  186. package/dist/testing/integration.d.ts.map +1 -1
  187. package/dist/testing/integration.js +15 -2
  188. package/dist/testing/round_trip.d.ts +0 -3
  189. package/dist/testing/round_trip.d.ts.map +1 -1
  190. package/dist/testing/round_trip.js +0 -4
  191. package/dist/testing/rpc_attack_surface.js +2 -2
  192. package/dist/testing/rpc_round_trip.d.ts +0 -3
  193. package/dist/testing/rpc_round_trip.d.ts.map +1 -1
  194. package/dist/testing/rpc_round_trip.js +0 -1
  195. package/dist/testing/schema_generators.d.ts +15 -0
  196. package/dist/testing/schema_generators.d.ts.map +1 -1
  197. package/dist/testing/schema_generators.js +45 -11
  198. package/dist/testing/standard.d.ts +5 -1
  199. package/dist/testing/standard.d.ts.map +1 -1
  200. package/dist/testing/standard.js +2 -7
  201. package/dist/timestamp.d.ts +58 -0
  202. package/dist/timestamp.d.ts.map +1 -0
  203. package/dist/timestamp.js +57 -0
  204. package/dist/ui/AdminOverview.svelte +5 -10
  205. package/dist/ui/AdminOverview.svelte.d.ts.map +1 -1
  206. package/dist/ui/RoleGrantOfferHistory.svelte +3 -1
  207. package/dist/ui/RoleGrantOfferHistory.svelte.d.ts.map +1 -1
  208. package/dist/ui/role_grant_offers_state.svelte.d.ts.map +1 -1
  209. package/dist/ui/role_grant_offers_state.svelte.js +6 -2
  210. package/package.json +1 -1
@@ -22,18 +22,53 @@
22
22
  */
23
23
  import { fact_hash_extract_refs } from '@fuzdev/fuz_util/fact_hash.ts';
24
24
  import { assert_row } from './assert_row.js';
25
+ import { iso8601_timestamp_expr, qualify_columns } from './sql_columns.js';
25
26
  /**
26
- * SQL fragment for the `grant_count` projection — correlated subquery
27
- * against `cell_grant`. Inlined in every cell-row SELECT / RETURNING so
28
- * `CellRow` carries the count uniformly. Pass the cell alias used in
29
- * the outer query (`'cell'` for table-name references, `'c'` for the
30
- * aliased form in `query_cell_list`).
27
+ * The full `cell` column set, named explicitly so a row read fails loud on
28
+ * schema drift — a `SELECT *` silently omits a dropped column and the
29
+ * `deleted_at IS NULL`-shaped predicates on the hydrated row then misread
30
+ * `undefined` (see `ACCOUNT_COLUMNS` in `auth/account_queries.ts` for the
31
+ * outage class). Column order mirrors the Rust twin's `cell_columns(alias)`
32
+ * (`fuz_cell`), which decodes the same projection positionally. The derived
33
+ * `grant_count` is not a column — `cell_row_projection` appends it. Keep
34
+ * in sync with `CellRow` and the `cell` DDL in `db/cell_ddl.ts`.
35
+ */
36
+ export const CELL_COLUMNS = [
37
+ 'id',
38
+ 'data',
39
+ 'kind',
40
+ 'visibility',
41
+ 'path',
42
+ 'refs',
43
+ 'parent_id',
44
+ 'root_id',
45
+ 'moderation',
46
+ 'created_at',
47
+ 'updated_at',
48
+ 'deleted_at',
49
+ 'created_by',
50
+ 'updated_by'
51
+ ];
52
+ /** The `cell` timestamp override, by row qualifier — the one `cell_row_projection` is given. */
53
+ const cell_expr = iso8601_timestamp_expr(CELL_COLUMNS, ['created_at', 'updated_at', 'deleted_at']);
54
+ /**
55
+ * The full `CellRow` projection — `CELL_COLUMNS` qualified by `alias` plus
56
+ * the derived `grant_count` (a correlated subquery against `cell_grant`,
57
+ * served by `idx_cell_grant_cell`) — used by every cell-row SELECT /
58
+ * RETURNING. `alias` is the row qualifier: the table name (`'cell'`) for
59
+ * single-table reads + `RETURNING`, the query alias (`'c'`) for
60
+ * `query_cell_list`. Twin of `fuz_cell`'s `cell_columns(alias)`.
61
+ *
62
+ * `::int` narrows the count from `bigint` so the JS row hydrates a `number`
63
+ * rather than a bigint primitive — counts on a single cell are trivially
64
+ * within int32.
31
65
  *
32
- * `::int` narrows from `bigint` to `int` so the JS row hydrates as a
33
- * `number` rather than a bigint primitive — counts on a single cell are
34
- * trivially within int32.
66
+ * Exported for consumers writing their own cell-row reads: `CELL_COLUMNS`
67
+ * alone hydrates a row without `grant_count`, which typechecks as a
68
+ * `CellRow` but fails at `to_cell_json` — this is the projection that
69
+ * actually produces one.
35
70
  */
36
- const grant_count_projection = (cell_alias) => `(SELECT COUNT(*)::int FROM cell_grant WHERE cell_id = ${cell_alias}.id) AS grant_count`;
71
+ export const cell_row_projection = (alias) => `${qualify_columns(CELL_COLUMNS, alias, cell_expr(alias))}, (SELECT COUNT(*)::int FROM cell_grant WHERE cell_id = ${alias}.id) AS grant_count`;
37
72
  /**
38
73
  * Insert a cell row, deriving `refs` from `data`.
39
74
  *
@@ -53,7 +88,7 @@ export const query_cell_create = async (deps, input) => {
53
88
  const row = await deps.db.query_one(`INSERT INTO cell
54
89
  (data, kind, visibility, path, refs, parent_id, root_id, moderation, created_by)
55
90
  VALUES ($1::jsonb, $2, COALESCE($3::cell_visibility, 'private'::cell_visibility), $4, $5::text[], $6, $7, $8, $9)
56
- RETURNING *, ${grant_count_projection('cell')}`, [
91
+ RETURNING ${cell_row_projection('cell')}`, [
57
92
  JSON.stringify(input.data),
58
93
  input.kind ?? null,
59
94
  input.visibility ?? null,
@@ -76,7 +111,7 @@ export const query_cell_create = async (deps, input) => {
76
111
  */
77
112
  export const query_cell_get = async (deps, id, options) => {
78
113
  const include_deleted = options?.include_deleted === true;
79
- const row = await deps.db.query_one(`SELECT *, ${grant_count_projection('cell')}
114
+ const row = await deps.db.query_one(`SELECT ${cell_row_projection('cell')}
80
115
  FROM cell
81
116
  WHERE id = $1
82
117
  AND ($2::bool OR deleted_at IS NULL)`, [id, include_deleted]);
@@ -92,7 +127,7 @@ export const query_cell_get = async (deps, id, options) => {
92
127
  * @returns the row or `null` when not found
93
128
  */
94
129
  export const query_cell_get_by_path = async (deps, path) => {
95
- const row = await deps.db.query_one(`SELECT *, ${grant_count_projection('cell')}
130
+ const row = await deps.db.query_one(`SELECT ${cell_row_projection('cell')}
96
131
  FROM cell
97
132
  WHERE path = $1 AND deleted_at IS NULL`, [path]);
98
133
  return row ?? null;
@@ -111,7 +146,7 @@ export const query_cell_get_by_path = async (deps, path) => {
111
146
  export const query_cell_load_many = async (deps, ids) => {
112
147
  if (ids.length === 0)
113
148
  return [];
114
- return deps.db.query(`SELECT *, ${grant_count_projection('cell')}
149
+ return deps.db.query(`SELECT ${cell_row_projection('cell')}
115
150
  FROM cell
116
151
  WHERE id = ANY($1::uuid[]) AND deleted_at IS NULL`, [ids]);
117
152
  };
@@ -140,7 +175,7 @@ export const query_cell_update = async (deps, id, patch) => {
140
175
  visibility = CASE WHEN $9::bool THEN $10::cell_visibility ELSE visibility END,
141
176
  updated_at = NOW()
142
177
  WHERE id = $1 AND deleted_at IS NULL
143
- RETURNING *, ${grant_count_projection('cell')}`, [
178
+ RETURNING ${cell_row_projection('cell')}`, [
144
179
  id,
145
180
  data_provided,
146
181
  data_provided ? JSON.stringify(patch.data) : null,
@@ -199,7 +234,7 @@ export const query_cell_set_moderation = async (deps, id, moderation, options) =
199
234
  updated_by = $4,
200
235
  updated_at = NOW()
201
236
  WHERE id = $1 AND deleted_at IS NULL
202
- RETURNING *, ${grant_count_projection('cell')}`, [id, moderation, options.set_visibility_public, options.updated_by ?? null]);
237
+ RETURNING ${cell_row_projection('cell')}`, [id, moderation, options.set_visibility_public, options.updated_by ?? null]);
203
238
  return row ?? null;
204
239
  };
205
240
  /**
@@ -211,11 +246,11 @@ export const query_cell_set_moderation = async (deps, id, moderation, options) =
211
246
  * @param options - pagination
212
247
  * @returns matching active rows
213
248
  */
214
- export const query_cell_list_by_kind = async (deps, kind, options) => deps.db.query(`SELECT *, ${grant_count_projection('cell')}
249
+ export const query_cell_list_by_kind = async (deps, kind, options) => deps.db.query(`SELECT ${cell_row_projection('cell')}
215
250
  FROM cell
216
251
  WHERE kind = $1
217
252
  AND deleted_at IS NULL
218
- ORDER BY created_at DESC
253
+ ORDER BY cell.created_at DESC
219
254
  LIMIT $2 OFFSET $3`, [kind, options?.limit ?? null, options?.offset ?? 0]);
220
255
  /**
221
256
  * List active cells created by an actor, newest first. Backed by the
@@ -226,10 +261,10 @@ export const query_cell_list_by_kind = async (deps, kind, options) => deps.db.qu
226
261
  * @param options - pagination
227
262
  * @returns matching active rows
228
263
  */
229
- export const query_cell_list_by_creator = async (deps, actor_id, options) => deps.db.query(`SELECT *, ${grant_count_projection('cell')}
264
+ export const query_cell_list_by_creator = async (deps, actor_id, options) => deps.db.query(`SELECT ${cell_row_projection('cell')}
230
265
  FROM cell
231
266
  WHERE created_by = $1 AND deleted_at IS NULL
232
- ORDER BY created_at DESC
267
+ ORDER BY cell.created_at DESC
233
268
  LIMIT $2 OFFSET $3`, [actor_id, options?.limit ?? null, options?.offset ?? 0]);
234
269
  /**
235
270
  * Filterable list query for the generic `cell_list` RPC.
@@ -365,7 +400,7 @@ const CALLER_ROLE_GRANTS_CTE = `WITH caller_role_grants AS (
365
400
  )`;
366
401
  /** General `cell_list` SQL: cell-driven scan, full visibility predicate. */
367
402
  const build_general_sql = (order_column, order_direction) => `${CALLER_ROLE_GRANTS_CTE}
368
- SELECT c.*, ${grant_count_projection('c')} FROM cell c
403
+ SELECT ${cell_row_projection('c')} FROM cell c
369
404
  WHERE ($1::bool OR c.deleted_at IS NULL)
370
405
  AND ($2::text IS NULL OR c.kind = $2::text)
371
406
  AND ($14::cell_visibility IS NULL OR c.visibility = $14::cell_visibility)
@@ -403,7 +438,7 @@ const build_general_sql = (order_column, order_direction) => `${CALLER_ROLE_GRAN
403
438
  * "what's shared with me" wants their grant footprint, not every cell.
404
439
  */
405
440
  const build_shared_with_sql = (order_column, order_direction) => `${CALLER_ROLE_GRANTS_CTE}
406
- SELECT c.*, ${grant_count_projection('c')} FROM cell c
441
+ SELECT ${cell_row_projection('c')} FROM cell c
407
442
  WHERE ($1::bool OR c.deleted_at IS NULL)
408
443
  AND ($2::text IS NULL OR c.kind = $2::text)
409
444
  AND ($14::cell_visibility IS NULL OR c.visibility = $14::cell_visibility)
@@ -422,7 +457,7 @@ const build_shared_with_sql = (order_column, order_direction) => `${CALLER_ROLE_
422
457
  -- $6 (viewer_is_admin) intentionally not consulted: an admin
423
458
  -- asking "what's shared with me" wants their grant footprint, not
424
459
  -- every cell. Cast-only reference keeps the param's type known to
425
- -- the planner so the shared 14-param positional layout stays valid.
460
+ -- the planner so the shared 16-param positional layout stays valid.
426
461
  AND $6::bool IS NOT NULL
427
462
  ORDER BY c.${order_column} ${order_direction} NULLS LAST
428
463
  LIMIT $8 OFFSET $9`;
@@ -22,6 +22,13 @@ export interface FactRow {
22
22
  size: number | string;
23
23
  created_at: Date;
24
24
  }
25
+ /**
26
+ * The full `fact` column set, named explicitly so a row read fails loud on
27
+ * schema drift (see `ACCOUNT_COLUMNS` in `auth/account_queries.ts` for the
28
+ * outage class). Keep in sync with `FactRow` and the `fact` DDL in
29
+ * `db/fact_ddl.ts`.
30
+ */
31
+ export declare const FACT_COLUMNS: readonly ["hash", "bytes", "external_url", "content_type", "size", "created_at"];
25
32
  /** Subset returned by metadata-only queries (no `bytes` payload). */
26
33
  export interface FactMetaRow {
27
34
  hash: FactHash;
@@ -1 +1 @@
1
- {"version":3,"file":"fact_queries.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/db/fact_queries.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"fact_queries.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/db/fact_queries.ts"],"names":[],"mappings":"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"}
@@ -11,6 +11,29 @@
11
11
  *
12
12
  * @module
13
13
  */
14
+ import { columns_sql, iso8601_timestamp_column, omit_columns } from './sql_columns.js';
15
+ /**
16
+ * The full `fact` column set, named explicitly so a row read fails loud on
17
+ * schema drift (see `ACCOUNT_COLUMNS` in `auth/account_queries.ts` for the
18
+ * outage class). Keep in sync with `FactRow` and the `fact` DDL in
19
+ * `db/fact_ddl.ts`.
20
+ */
21
+ export const FACT_COLUMNS = [
22
+ 'hash',
23
+ 'bytes',
24
+ 'external_url',
25
+ 'content_type',
26
+ 'size',
27
+ 'created_at'
28
+ ];
29
+ // `created_at` is deliberately *not* projected through
30
+ // `iso8601_timestamp_column` here, unlike every other table: these rows feed
31
+ // `PgFactStore`, whose `FactMeta.created_at` is a `Date` in the
32
+ // `@fuzdev/fuz_util` `FactStore` contract, and nothing serializes them. The
33
+ // one fact timestamp that does reach the wire — the orphan-sweep sample in
34
+ // `query_orphan_facts_list` — projects the ISO-8601 wire string.
35
+ /** `FACT_COLUMNS` minus the payload — the `FactMetaRow` projection. */
36
+ const FACT_META_COLUMNS = omit_columns(FACT_COLUMNS, 'bytes');
14
37
  /**
15
38
  * Idempotently insert a fact row.
16
39
  *
@@ -44,7 +67,7 @@ export const query_put_fact_refs = async (deps, source_hash, target_hashes) => {
44
67
  * `FactStore.get`; cheaper accessors live below.
45
68
  */
46
69
  export const query_get_fact = async (deps, hash) => {
47
- const row = await deps.db.query_one(`SELECT hash, bytes, external_url, content_type, size, created_at
70
+ const row = await deps.db.query_one(`SELECT ${columns_sql(FACT_COLUMNS)}
48
71
  FROM fact WHERE hash = $1`, [hash]);
49
72
  return row ?? null;
50
73
  };
@@ -52,7 +75,7 @@ export const query_get_fact = async (deps, hash) => {
52
75
  * Fetch metadata only — skips the (potentially large) `bytes` column.
53
76
  */
54
77
  export const query_get_fact_meta = async (deps, hash) => {
55
- const row = await deps.db.query_one(`SELECT hash, external_url, content_type, size, created_at
78
+ const row = await deps.db.query_one(`SELECT ${columns_sql(FACT_META_COLUMNS)}
56
79
  FROM fact WHERE hash = $1`, [hash]);
57
80
  return row ?? null;
58
81
  };
@@ -120,7 +143,7 @@ export const query_orphan_facts_list = async (deps, older_than, sample_limit) =>
120
143
  AND c.deleted_at IS NULL
121
144
  )
122
145
  AND ($1::timestamptz IS NULL OR f.created_at < $1::timestamptz)`, [older_than]);
123
- const sample_rows = await deps.db.query(`SELECT hash, size, created_at, external_url
146
+ const sample_rows = await deps.db.query(`SELECT hash, size, ${iso8601_timestamp_column('f', 'created_at')} AS created_at, external_url
124
147
  FROM fact f
125
148
  WHERE NOT EXISTS (
126
149
  SELECT 1 FROM cell c
@@ -136,7 +159,7 @@ export const query_orphan_facts_list = async (deps, older_than, sample_limit) =>
136
159
  sample: sample_rows.map((r) => ({
137
160
  hash: r.hash,
138
161
  size: Number(r.size),
139
- created_at: typeof r.created_at === 'string' ? r.created_at : r.created_at.toISOString(),
162
+ created_at: r.created_at,
140
163
  external_url: r.external_url
141
164
  }))
142
165
  };
@@ -47,9 +47,14 @@ export type ExpectedSchema = Record<string, ReadonlyArray<string>>;
47
47
  * never-migrated DB then correctly fails readiness instead of passing on an
48
48
  * empty expectation.
49
49
  *
50
+ * @param db - the database to introspect
51
+ * @param options - `table` narrows the scan to one relation (the drift guard's
52
+ * one-table question); omitted, every `public` relation is returned
50
53
  * @returns relation name → sorted column names
51
54
  */
52
- export declare const query_public_columns: (db: Db) => Promise<Record<string, Array<string>>>;
55
+ export declare const query_public_columns: (db: Db, options?: {
56
+ table?: string;
57
+ }) => Promise<Record<string, Array<string>>>;
53
58
  /** Columns the live DB is missing for a table the expected schema declares. */
54
59
  export interface MissingColumns {
55
60
  table: string;
@@ -1 +1 @@
1
- {"version":3,"file":"schema_ready.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/db/schema_ready.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AAEH,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAElC,iFAAiF;AACjF,MAAM,MAAM,cAAc,GAAG,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC,CAAC;AAOnE;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,oBAAoB,GAAU,IAAI,EAAE,KAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC,CAWxF,CAAC;AAEF,+EAA+E;AAC/E,MAAM,WAAW,cAAc;IAC9B,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;CACvB;AAED,uCAAuC;AACvC,MAAM,WAAW,iBAAiB;IACjC,EAAE,EAAE,OAAO,CAAC;IACZ,+CAA+C;IAC/C,cAAc,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;IAC9B,6EAA6E;IAC7E,eAAe,EAAE,KAAK,CAAC,cAAc,CAAC,CAAC;CACvC;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,kBAAkB,GAC9B,IAAI,EAAE,EACN,UAAU,cAAc,KACtB,OAAO,CAAC,iBAAiB,CAmB3B,CAAC;AAEF,qEAAqE;AACrE,eAAO,MAAM,mBAAmB,GAAI,OAAO,iBAAiB,KAAG,MAO9D,CAAC;AAEF,sFAAsF;AACtF,eAAO,MAAM,WAAW;;;CAGd,CAAC"}
1
+ {"version":3,"file":"schema_ready.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/db/schema_ready.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AAEH,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAElC,iFAAiF;AACjF,MAAM,MAAM,cAAc,GAAG,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC,CAAC;AAOnE;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,oBAAoB,GAChC,IAAI,EAAE,EACN,UAAU;IAAE,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,KAC1B,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC,CAavC,CAAC;AAEF,+EAA+E;AAC/E,MAAM,WAAW,cAAc;IAC9B,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;CACvB;AAED,uCAAuC;AACvC,MAAM,WAAW,iBAAiB;IACjC,EAAE,EAAE,OAAO,CAAC;IACZ,+CAA+C;IAC/C,cAAc,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;IAC9B,6EAA6E;IAC7E,eAAe,EAAE,KAAK,CAAC,cAAc,CAAC,CAAC;CACvC;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,kBAAkB,GAC9B,IAAI,EAAE,EACN,UAAU,cAAc,KACtB,OAAO,CAAC,iBAAiB,CAmB3B,CAAC;AAEF,qEAAqE;AACrE,eAAO,MAAM,mBAAmB,GAAI,OAAO,iBAAiB,KAAG,MAO9D,CAAC;AAEF,sFAAsF;AACtF,eAAO,MAAM,WAAW;;;CAGd,CAAC"}
@@ -44,12 +44,16 @@
44
44
  * never-migrated DB then correctly fails readiness instead of passing on an
45
45
  * empty expectation.
46
46
  *
47
+ * @param db - the database to introspect
48
+ * @param options - `table` narrows the scan to one relation (the drift guard's
49
+ * one-table question); omitted, every `public` relation is returned
47
50
  * @returns relation name → sorted column names
48
51
  */
49
- export const query_public_columns = async (db) => {
52
+ export const query_public_columns = async (db, options) => {
50
53
  const rows = await db.query(`SELECT table_name, column_name FROM information_schema.columns
51
54
  WHERE table_schema = 'public'
52
- ORDER BY table_name, column_name`);
55
+ AND ($1::text IS NULL OR table_name = $1)
56
+ ORDER BY table_name, column_name`, [options?.table ?? null]);
53
57
  const by_table = {};
54
58
  for (const { table_name, column_name } of rows) {
55
59
  (by_table[table_name] ??= []).push(column_name);
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Helpers for the named-column projection consts (`ACCOUNT_COLUMNS`,
3
+ * `CELL_COLUMNS`, …) — per-table column-name arrays that every table read
4
+ * projects through so a dropped or leftover column fails loud instead of
5
+ * vanishing under `SELECT *`.
6
+ *
7
+ * Each query module exports its table's const and derives every projection
8
+ * it needs from it at the read site — `columns_sql` for single-table reads,
9
+ * `qualify_columns` for reads that alias the table, `omit_columns` for a
10
+ * client-safe subset — rather than spelling a second column list, so the
11
+ * drift guard on the base const (`assert_columns_match_live` in
12
+ * `testing/db.ts`) covers every derived projection too. Twin of the Rust
13
+ * spine's `*_COLUMNS` consts + `fuz_db::qualify_columns` / `omit_columns`.
14
+ *
15
+ * A read that needs a column rendered as something other than the bare
16
+ * reference passes a `ColumnExpr` — the `expr` override, twin of the third
17
+ * argument to `fuz_db::qualify_columns`. Every timestamp that reaches the
18
+ * wire projects through `iso8601_timestamp_column` so both spines serialize
19
+ * the same instant to the same bytes; each query module builds its override
20
+ * with `iso8601_timestamp_expr` over its table const. An override costs one
21
+ * rule — a projected `ORDER BY` must qualify its column — stated on
22
+ * `qualify_columns`.
23
+ *
24
+ * Placement rule: a const lives in its table's query module. When two query
25
+ * modules project the same table (the revoke cascades in
26
+ * `auth/role_grant_queries.ts` supersede `role_grant_offer` rows while the
27
+ * offer module's accept path writes `role_grant` rows), it lives in the
28
+ * table's `*_ddl.ts` instead — the module both already import — so the query
29
+ * modules stay acyclic.
30
+ *
31
+ * Column arrays are literal consts; the alias passed to `qualify_columns` is
32
+ * the only input these helpers treat as dynamic (and validate). Each const
33
+ * also carries `satisfies ReadonlyArray<keyof Row>` so a column name the row
34
+ * type doesn't know is a compile error.
35
+ *
36
+ * @module
37
+ */
38
+ /**
39
+ * Per-column expression override for a projection — return the SQL to
40
+ * project for `column`, or `undefined` to keep the bare column reference.
41
+ *
42
+ * Twin of the `expr` closure `fuz_db::qualify_columns` takes. The Rust side
43
+ * decodes rows positionally, so its overrides need no output name; TS reads
44
+ * rows by name, so `columns_sql` / `qualify_columns` alias every override
45
+ * back to its column (`… AS created_at`) — otherwise a `to_char(…)`
46
+ * projection would arrive under Postgres' derived name and read as
47
+ * `undefined`.
48
+ *
49
+ * `undefined` is the *only* decline sentinel. An empty string is a returned
50
+ * expression, not a decline: it renders ` AS created_at` and the query fails
51
+ * to parse. This is documented rather than guarded — every override in the
52
+ * repo is a literal lookup returning a fragment or nothing, and a malformed
53
+ * projection failing loud at the first query beats silently swallowing an
54
+ * expression the caller meant to supply.
55
+ */
56
+ export type ColumnExpr = (column: string) => string | undefined;
57
+ /**
58
+ * Build a `to_char({alias}.{column} AT TIME ZONE 'UTC',
59
+ * 'YYYY-MM-DD"T"HH24:MI:SS"Z"')` fragment for embedding in a SELECT
60
+ * projection, via a `ColumnExpr` override.
61
+ *
62
+ * The format literal is the spine's canonical timestamp wire shape —
63
+ * second-precision UTC ISO-8601, exactly 20 characters — byte-identical to
64
+ * `fuz_db::iso8601_timestamp_column`, so the two backends serialize the same
65
+ * instant to the same bytes. `to_iso8601_seconds` (`timestamp.ts`) is the
66
+ * same shape for stamps minted in TS rather than read from Postgres.
67
+ *
68
+ * @param alias - the table alias or name to qualify the column with, or the
69
+ * empty string for an unqualified reference
70
+ * @param column - the timestamp column name
71
+ * @returns the `to_char(…)` fragment
72
+ */
73
+ export declare const iso8601_timestamp_column: (alias: string, column: string) => string;
74
+ /**
75
+ * Build a table's timestamp override: a `ColumnExpr` factory that projects
76
+ * each of `timestamp_columns` through `iso8601_timestamp_column` and declines
77
+ * every other column. The result takes the row qualifier — the empty string
78
+ * for a bare-table read, the alias for a JOIN — so one definition per query
79
+ * module serves every read in it.
80
+ *
81
+ * `timestamp_columns` is typed against `columns` (and checked at build time),
82
+ * so a misspelled name is a compile error and a stale one throws at module
83
+ * load rather than silently leaving a column projected as a `Date`.
84
+ *
85
+ * @param columns - the table's `*_COLUMNS` const
86
+ * @param timestamp_columns - the members of `columns` that carry timestamps
87
+ * @returns `(alias) => ColumnExpr`
88
+ * @throws Error when a name in `timestamp_columns` is not in `columns`
89
+ */
90
+ export declare const iso8601_timestamp_expr: <T extends string>(columns: ReadonlyArray<T>, timestamp_columns: ReadonlyArray<NoInfer<T>>) => ((alias: string) => ColumnExpr);
91
+ /**
92
+ * Render a `*_COLUMNS` const as a SQL select list, in projection order.
93
+ *
94
+ * @param columns - the column names
95
+ * @param expr - optional per-column expression override; an overridden
96
+ * column is aliased back to its own name
97
+ * @returns `a, b, c`
98
+ */
99
+ export declare const columns_sql: (columns: ReadonlyArray<string>, expr?: ColumnExpr) => string;
100
+ /**
101
+ * Render a `*_COLUMNS` const as a SQL select list with every column qualified
102
+ * by a table alias, for reads that alias the table (JOINs, the `c`-aliased
103
+ * `cell_list` scan).
104
+ *
105
+ * The alias is interpolated into SQL, so it must be a plain identifier —
106
+ * every in-repo call passes a literal, but a consumer threading a computed
107
+ * alias through gets a thrown error instead of a malformed (or injectable)
108
+ * projection.
109
+ *
110
+ * **An `expr` override costs one rule: an `ORDER BY` in a projected SELECT
111
+ * must qualify its column.** An override is aliased back to its own name
112
+ * (`… AS created_at`), and Postgres resolves a bare `ORDER BY created_at`
113
+ * against the *output* names first, so it would sort on the `to_char(…)`
114
+ * text — collapsing sub-second order into ties and losing the btree index (a
115
+ * `Sort` node over the expression instead of an `Index Scan`). Writing
116
+ * `ORDER BY <alias>.created_at` (or `<table>.created_at`) names the input
117
+ * column and both go away. The Rust twin needs no such rule: it decodes
118
+ * positionally, so `fuz_db::qualify_columns` emits no alias and its bare
119
+ * `ORDER BY created_at` already means the column.
120
+ *
121
+ * @param columns - the column names
122
+ * @param alias - the table alias or name to prefix each column with
123
+ * @param expr - optional per-column expression override; an overridden
124
+ * column is aliased back to its own name
125
+ * @returns `alias.a, alias.b, alias.c`
126
+ * @throws Error when `alias` is not a valid SQL identifier
127
+ */
128
+ export declare const qualify_columns: (columns: ReadonlyArray<string>, alias: string, expr?: ColumnExpr) => string;
129
+ /**
130
+ * Drop named columns from a `*_COLUMNS` const, preserving projection order —
131
+ * for a read that deliberately narrows the row (a client-safe listing, a
132
+ * metadata read that skips a payload column).
133
+ *
134
+ * With an `as const` column array the omitted names are checked at compile
135
+ * time; the runtime check covers plain `string[]` consts (a consumer's).
136
+ *
137
+ * @param columns - the column names
138
+ * @param omitted - column names to leave out
139
+ * @returns the columns without the omitted ones, in projection order
140
+ * @throws Error when an omitted name isn't in the list — a typo here would
141
+ * silently keep the column it meant to hide
142
+ */
143
+ export declare const omit_columns: <T extends string>(columns: ReadonlyArray<T>, ...omitted: Array<NoInfer<T>>) => ReadonlyArray<T>;
144
+ //# sourceMappingURL=sql_columns.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sql_columns.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/db/sql_columns.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AAIH;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,MAAM,UAAU,GAAG,CAAC,MAAM,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,CAAC;AAEhE;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,wBAAwB,GAAI,OAAO,MAAM,EAAE,QAAQ,MAAM,KAAG,MAGS,CAAC;AAEnF;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,sBAAsB,GAAI,CAAC,SAAS,MAAM,EACtD,SAAS,aAAa,CAAC,CAAC,CAAC,EACzB,mBAAmB,aAAa,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,KAC1C,CAAC,CAAC,KAAK,EAAE,MAAM,KAAK,UAAU,CAYhC,CAAC;AAqBF;;;;;;;GAOG;AACH,eAAO,MAAM,WAAW,GAAI,SAAS,aAAa,CAAC,MAAM,CAAC,EAAE,OAAO,UAAU,KAAG,MACvB,CAAC;AAE1D;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,eAAO,MAAM,eAAe,GAC3B,SAAS,aAAa,CAAC,MAAM,CAAC,EAC9B,OAAO,MAAM,EACb,OAAO,UAAU,KACf,MAGF,CAAC;AAEF;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,YAAY,GAAI,CAAC,SAAS,MAAM,EAC5C,SAAS,aAAa,CAAC,CAAC,CAAC,EACzB,GAAG,SAAS,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,KAC3B,aAAa,CAAC,CAAC,CAOjB,CAAC"}
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Helpers for the named-column projection consts (`ACCOUNT_COLUMNS`,
3
+ * `CELL_COLUMNS`, …) — per-table column-name arrays that every table read
4
+ * projects through so a dropped or leftover column fails loud instead of
5
+ * vanishing under `SELECT *`.
6
+ *
7
+ * Each query module exports its table's const and derives every projection
8
+ * it needs from it at the read site — `columns_sql` for single-table reads,
9
+ * `qualify_columns` for reads that alias the table, `omit_columns` for a
10
+ * client-safe subset — rather than spelling a second column list, so the
11
+ * drift guard on the base const (`assert_columns_match_live` in
12
+ * `testing/db.ts`) covers every derived projection too. Twin of the Rust
13
+ * spine's `*_COLUMNS` consts + `fuz_db::qualify_columns` / `omit_columns`.
14
+ *
15
+ * A read that needs a column rendered as something other than the bare
16
+ * reference passes a `ColumnExpr` — the `expr` override, twin of the third
17
+ * argument to `fuz_db::qualify_columns`. Every timestamp that reaches the
18
+ * wire projects through `iso8601_timestamp_column` so both spines serialize
19
+ * the same instant to the same bytes; each query module builds its override
20
+ * with `iso8601_timestamp_expr` over its table const. An override costs one
21
+ * rule — a projected `ORDER BY` must qualify its column — stated on
22
+ * `qualify_columns`.
23
+ *
24
+ * Placement rule: a const lives in its table's query module. When two query
25
+ * modules project the same table (the revoke cascades in
26
+ * `auth/role_grant_queries.ts` supersede `role_grant_offer` rows while the
27
+ * offer module's accept path writes `role_grant` rows), it lives in the
28
+ * table's `*_ddl.ts` instead — the module both already import — so the query
29
+ * modules stay acyclic.
30
+ *
31
+ * Column arrays are literal consts; the alias passed to `qualify_columns` is
32
+ * the only input these helpers treat as dynamic (and validate). Each const
33
+ * also carries `satisfies ReadonlyArray<keyof Row>` so a column name the row
34
+ * type doesn't know is a compile error.
35
+ *
36
+ * @module
37
+ */
38
+ import { assert_valid_sql_identifier } from './sql_identifier.js';
39
+ /**
40
+ * Build a `to_char({alias}.{column} AT TIME ZONE 'UTC',
41
+ * 'YYYY-MM-DD"T"HH24:MI:SS"Z"')` fragment for embedding in a SELECT
42
+ * projection, via a `ColumnExpr` override.
43
+ *
44
+ * The format literal is the spine's canonical timestamp wire shape —
45
+ * second-precision UTC ISO-8601, exactly 20 characters — byte-identical to
46
+ * `fuz_db::iso8601_timestamp_column`, so the two backends serialize the same
47
+ * instant to the same bytes. `to_iso8601_seconds` (`timestamp.ts`) is the
48
+ * same shape for stamps minted in TS rather than read from Postgres.
49
+ *
50
+ * @param alias - the table alias or name to qualify the column with, or the
51
+ * empty string for an unqualified reference
52
+ * @param column - the timestamp column name
53
+ * @returns the `to_char(…)` fragment
54
+ */
55
+ export const iso8601_timestamp_column = (alias, column) => alias === ''
56
+ ? `to_char(${column} AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`
57
+ : `to_char(${alias}.${column} AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`;
58
+ /**
59
+ * Build a table's timestamp override: a `ColumnExpr` factory that projects
60
+ * each of `timestamp_columns` through `iso8601_timestamp_column` and declines
61
+ * every other column. The result takes the row qualifier — the empty string
62
+ * for a bare-table read, the alias for a JOIN — so one definition per query
63
+ * module serves every read in it.
64
+ *
65
+ * `timestamp_columns` is typed against `columns` (and checked at build time),
66
+ * so a misspelled name is a compile error and a stale one throws at module
67
+ * load rather than silently leaving a column projected as a `Date`.
68
+ *
69
+ * @param columns - the table's `*_COLUMNS` const
70
+ * @param timestamp_columns - the members of `columns` that carry timestamps
71
+ * @returns `(alias) => ColumnExpr`
72
+ * @throws Error when a name in `timestamp_columns` is not in `columns`
73
+ */
74
+ export const iso8601_timestamp_expr = (columns, timestamp_columns) => {
75
+ for (const t of timestamp_columns) {
76
+ if (!columns.includes(t)) {
77
+ throw new Error(`iso8601_timestamp_expr: "${t}" is not in the column list "${columns.join(', ')}"`);
78
+ }
79
+ }
80
+ return (alias) => (column) => timestamp_columns.includes(column)
81
+ ? iso8601_timestamp_column(alias, column)
82
+ : undefined;
83
+ };
84
+ /**
85
+ * Render one projection entry — the `expr` override aliased back to its
86
+ * column name, or `fallback` when the override declines the column
87
+ * (`undefined`, and only `undefined` — see `ColumnExpr`).
88
+ *
89
+ * The alias this adds is what makes the `ORDER BY` rule on `qualify_columns`
90
+ * necessary.
91
+ *
92
+ * @param column - the column name, and the output name an override is aliased to
93
+ * @param fallback - what to project when the override declines — the bare
94
+ * column, or the alias-qualified reference
95
+ * @param expr - the per-column expression override, if the read has one
96
+ * @returns the projection entry, `<expr> AS <column>` or `fallback`
97
+ */
98
+ const render_column = (column, fallback, expr) => {
99
+ const override = expr?.(column);
100
+ return override === undefined ? fallback : `${override} AS ${column}`;
101
+ };
102
+ /**
103
+ * Render a `*_COLUMNS` const as a SQL select list, in projection order.
104
+ *
105
+ * @param columns - the column names
106
+ * @param expr - optional per-column expression override; an overridden
107
+ * column is aliased back to its own name
108
+ * @returns `a, b, c`
109
+ */
110
+ export const columns_sql = (columns, expr) => columns.map((c) => render_column(c, c, expr)).join(', ');
111
+ /**
112
+ * Render a `*_COLUMNS` const as a SQL select list with every column qualified
113
+ * by a table alias, for reads that alias the table (JOINs, the `c`-aliased
114
+ * `cell_list` scan).
115
+ *
116
+ * The alias is interpolated into SQL, so it must be a plain identifier —
117
+ * every in-repo call passes a literal, but a consumer threading a computed
118
+ * alias through gets a thrown error instead of a malformed (or injectable)
119
+ * projection.
120
+ *
121
+ * **An `expr` override costs one rule: an `ORDER BY` in a projected SELECT
122
+ * must qualify its column.** An override is aliased back to its own name
123
+ * (`… AS created_at`), and Postgres resolves a bare `ORDER BY created_at`
124
+ * against the *output* names first, so it would sort on the `to_char(…)`
125
+ * text — collapsing sub-second order into ties and losing the btree index (a
126
+ * `Sort` node over the expression instead of an `Index Scan`). Writing
127
+ * `ORDER BY <alias>.created_at` (or `<table>.created_at`) names the input
128
+ * column and both go away. The Rust twin needs no such rule: it decodes
129
+ * positionally, so `fuz_db::qualify_columns` emits no alias and its bare
130
+ * `ORDER BY created_at` already means the column.
131
+ *
132
+ * @param columns - the column names
133
+ * @param alias - the table alias or name to prefix each column with
134
+ * @param expr - optional per-column expression override; an overridden
135
+ * column is aliased back to its own name
136
+ * @returns `alias.a, alias.b, alias.c`
137
+ * @throws Error when `alias` is not a valid SQL identifier
138
+ */
139
+ export const qualify_columns = (columns, alias, expr) => {
140
+ assert_valid_sql_identifier(alias);
141
+ return columns.map((c) => render_column(c, `${alias}.${c}`, expr)).join(', ');
142
+ };
143
+ /**
144
+ * Drop named columns from a `*_COLUMNS` const, preserving projection order —
145
+ * for a read that deliberately narrows the row (a client-safe listing, a
146
+ * metadata read that skips a payload column).
147
+ *
148
+ * With an `as const` column array the omitted names are checked at compile
149
+ * time; the runtime check covers plain `string[]` consts (a consumer's).
150
+ *
151
+ * @param columns - the column names
152
+ * @param omitted - column names to leave out
153
+ * @returns the columns without the omitted ones, in projection order
154
+ * @throws Error when an omitted name isn't in the list — a typo here would
155
+ * silently keep the column it meant to hide
156
+ */
157
+ export const omit_columns = (columns, ...omitted) => {
158
+ for (const o of omitted) {
159
+ if (!columns.includes(o)) {
160
+ throw new Error(`omit_columns: "${o}" is not in the column list "${columns.join(', ')}"`);
161
+ }
162
+ }
163
+ return columns.filter((c) => !omitted.includes(c));
164
+ };
@@ -1,10 +1,11 @@
1
1
  /**
2
- * SQL identifier validation for dynamic DDL queries.
2
+ * SQL identifier validation for the places an identifier must be
3
+ * interpolated rather than parameterized.
3
4
  *
4
- * PostgreSQL DDL operations (DROP TABLE, TRUNCATE, ALTER) do not support
5
- * parameterized table/column names — only values can be parameterized.
6
- * This validator ensures identifiers are safe for string interpolation
7
- * in those specific cases.
5
+ * PostgreSQL parameterizes values only — table and column names in DDL
6
+ * (DROP TABLE, TRUNCATE, ALTER) and the alias `qualify_columns` prefixes
7
+ * onto a projection (`db/sql_columns.ts`) have to be string-interpolated.
8
+ * This validator ensures such identifiers are safe to interpolate.
8
9
  *
9
10
  * @module
10
11
  */
@@ -1 +1 @@
1
- {"version":3,"file":"sql_identifier.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/db/sql_identifier.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH;;;GAGG;AACH,eAAO,MAAM,oBAAoB,QAA6B,CAAC;AAE/D;;;;;;;;;;GAUG;AACH,eAAO,MAAM,2BAA2B,GAAI,MAAM,MAAM,KAAG,MAK1D,CAAC"}
1
+ {"version":3,"file":"sql_identifier.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/db/sql_identifier.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH;;;GAGG;AACH,eAAO,MAAM,oBAAoB,QAA6B,CAAC;AAE/D;;;;;;;;;;GAUG;AACH,eAAO,MAAM,2BAA2B,GAAI,MAAM,MAAM,KAAG,MAK1D,CAAC"}