@fuzdev/fuz_app 0.67.1 → 0.68.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.
- package/dist/auth/CLAUDE.md +99 -5
- package/dist/auth/account_queries.d.ts +87 -4
- package/dist/auth/account_queries.d.ts.map +1 -1
- package/dist/auth/account_queries.js +107 -17
- package/dist/auth/account_schema.d.ts +19 -0
- package/dist/auth/account_schema.d.ts.map +1 -1
- package/dist/auth/account_schema.js +8 -0
- package/dist/auth/admin_action_specs.d.ts +168 -0
- package/dist/auth/admin_action_specs.d.ts.map +1 -1
- package/dist/auth/admin_action_specs.js +146 -1
- package/dist/auth/admin_actions.d.ts.map +1 -1
- package/dist/auth/admin_actions.js +218 -4
- package/dist/auth/audit_log_ddl.d.ts +10 -1
- package/dist/auth/audit_log_ddl.d.ts.map +1 -1
- package/dist/auth/audit_log_ddl.js +13 -4
- package/dist/auth/audit_log_schema.d.ts +34 -1
- package/dist/auth/audit_log_schema.d.ts.map +1 -1
- package/dist/auth/audit_log_schema.js +73 -0
- package/dist/auth/auth_ddl.d.ts +2 -2
- package/dist/auth/auth_ddl.d.ts.map +1 -1
- package/dist/auth/auth_ddl.js +10 -2
- package/dist/auth/cell_action_specs.d.ts +1295 -0
- package/dist/auth/cell_action_specs.d.ts.map +1 -0
- package/dist/auth/cell_action_specs.js +397 -0
- package/dist/auth/cell_actions.d.ts +63 -0
- package/dist/auth/cell_actions.d.ts.map +1 -0
- package/dist/auth/cell_actions.js +546 -0
- package/dist/auth/cell_audit_action_specs.d.ts +131 -0
- package/dist/auth/cell_audit_action_specs.d.ts.map +1 -0
- package/dist/auth/cell_audit_action_specs.js +70 -0
- package/dist/auth/cell_audit_actions.d.ts +18 -0
- package/dist/auth/cell_audit_actions.d.ts.map +1 -0
- package/dist/auth/cell_audit_actions.js +59 -0
- package/dist/auth/cell_audit_events.d.ts +28 -0
- package/dist/auth/cell_audit_events.d.ts.map +1 -0
- package/dist/auth/cell_audit_events.js +42 -0
- package/dist/auth/cell_audit_metadata.d.ts +48 -0
- package/dist/auth/cell_audit_metadata.d.ts.map +1 -0
- package/dist/auth/cell_audit_metadata.js +46 -0
- package/dist/auth/cell_authorize.d.ts +88 -0
- package/dist/auth/cell_authorize.d.ts.map +1 -0
- package/dist/auth/cell_authorize.js +172 -0
- package/dist/auth/cell_data_schema.d.ts +44 -0
- package/dist/auth/cell_data_schema.d.ts.map +1 -0
- package/dist/auth/cell_data_schema.js +42 -0
- package/dist/auth/cell_field_action_specs.d.ts +244 -0
- package/dist/auth/cell_field_action_specs.d.ts.map +1 -0
- package/dist/auth/cell_field_action_specs.js +136 -0
- package/dist/auth/cell_field_actions.d.ts +34 -0
- package/dist/auth/cell_field_actions.d.ts.map +1 -0
- package/dist/auth/cell_field_actions.js +153 -0
- package/dist/auth/cell_field_audit_metadata.d.ts +30 -0
- package/dist/auth/cell_field_audit_metadata.d.ts.map +1 -0
- package/dist/auth/cell_field_audit_metadata.js +28 -0
- package/dist/auth/cell_grant_action_specs.d.ts +333 -0
- package/dist/auth/cell_grant_action_specs.d.ts.map +1 -0
- package/dist/auth/cell_grant_action_specs.js +148 -0
- package/dist/auth/cell_grant_actions.d.ts +50 -0
- package/dist/auth/cell_grant_actions.d.ts.map +1 -0
- package/dist/auth/cell_grant_actions.js +208 -0
- package/dist/auth/cell_grant_audit_metadata.d.ts +75 -0
- package/dist/auth/cell_grant_audit_metadata.d.ts.map +1 -0
- package/dist/auth/cell_grant_audit_metadata.js +54 -0
- package/dist/auth/cell_item_action_specs.d.ts +331 -0
- package/dist/auth/cell_item_action_specs.d.ts.map +1 -0
- package/dist/auth/cell_item_action_specs.js +182 -0
- package/dist/auth/cell_item_actions.d.ts +37 -0
- package/dist/auth/cell_item_actions.d.ts.map +1 -0
- package/dist/auth/cell_item_actions.js +204 -0
- package/dist/auth/cell_item_audit_metadata.d.ts +35 -0
- package/dist/auth/cell_item_audit_metadata.d.ts.map +1 -0
- package/dist/auth/cell_item_audit_metadata.js +32 -0
- package/dist/auth/cell_relation_visibility.d.ts +32 -0
- package/dist/auth/cell_relation_visibility.d.ts.map +1 -0
- package/dist/auth/cell_relation_visibility.js +57 -0
- package/dist/auth/deps.d.ts +9 -0
- package/dist/auth/deps.d.ts.map +1 -1
- package/dist/auth/role_grant_queries.d.ts +30 -0
- package/dist/auth/role_grant_queries.d.ts.map +1 -1
- package/dist/auth/role_grant_queries.js +54 -0
- package/dist/db/CLAUDE.md +118 -0
- package/dist/db/cell_audit_queries.d.ts +26 -0
- package/dist/db/cell_audit_queries.d.ts.map +1 -0
- package/dist/db/cell_audit_queries.js +53 -0
- package/dist/db/cell_ddl.d.ts +151 -0
- package/dist/db/cell_ddl.d.ts.map +1 -0
- package/dist/db/cell_ddl.js +247 -0
- package/dist/db/cell_field_queries.d.ts +105 -0
- package/dist/db/cell_field_queries.d.ts.map +1 -0
- package/dist/db/cell_field_queries.js +113 -0
- package/dist/db/cell_grant_queries.d.ts +132 -0
- package/dist/db/cell_grant_queries.d.ts.map +1 -0
- package/dist/db/cell_grant_queries.js +145 -0
- package/dist/db/cell_history_ddl.d.ts +38 -0
- package/dist/db/cell_history_ddl.d.ts.map +1 -0
- package/dist/db/cell_history_ddl.js +61 -0
- package/dist/db/cell_item_queries.d.ts +107 -0
- package/dist/db/cell_item_queries.d.ts.map +1 -0
- package/dist/db/cell_item_queries.js +119 -0
- package/dist/db/cell_queries.d.ts +327 -0
- package/dist/db/cell_queries.d.ts.map +1 -0
- package/dist/db/cell_queries.js +431 -0
- package/dist/db/fact_ddl.d.ts +38 -0
- package/dist/db/fact_ddl.d.ts.map +1 -0
- package/dist/db/fact_ddl.js +71 -0
- package/dist/db/fact_queries.d.ts +140 -0
- package/dist/db/fact_queries.d.ts.map +1 -0
- package/dist/db/fact_queries.js +161 -0
- package/dist/db/fact_store.d.ts +112 -0
- package/dist/db/fact_store.d.ts.map +1 -0
- package/dist/db/fact_store.js +225 -0
- package/dist/server/env.d.ts +2 -0
- package/dist/server/env.d.ts.map +1 -1
- package/dist/server/env.js +6 -0
- package/dist/server/fact_write.d.ts +32 -0
- package/dist/server/fact_write.d.ts.map +1 -0
- package/dist/server/fact_write.js +56 -0
- package/dist/server/file_fact_fetcher.d.ts +42 -0
- package/dist/server/file_fact_fetcher.d.ts.map +1 -0
- package/dist/server/file_fact_fetcher.js +60 -0
- package/dist/server/file_fact_url.d.ts +53 -0
- package/dist/server/file_fact_url.d.ts.map +1 -0
- package/dist/server/file_fact_url.js +52 -0
- package/dist/server/serve_fact_route.d.ts +78 -0
- package/dist/server/serve_fact_route.d.ts.map +1 -0
- package/dist/server/serve_fact_route.js +205 -0
- package/dist/testing/CLAUDE.md +58 -5
- package/dist/testing/app_server.d.ts +12 -0
- package/dist/testing/app_server.d.ts.map +1 -1
- package/dist/testing/app_server.js +36 -2
- package/dist/testing/audit_completeness.d.ts.map +1 -1
- package/dist/testing/audit_completeness.js +67 -1
- package/dist/testing/cross_backend/account_lifecycle.d.ts +10 -0
- package/dist/testing/cross_backend/account_lifecycle.d.ts.map +1 -0
- package/dist/testing/cross_backend/account_lifecycle.js +76 -0
- package/dist/testing/cross_backend/capabilities.d.ts +31 -0
- package/dist/testing/cross_backend/capabilities.d.ts.map +1 -1
- package/dist/testing/cross_backend/capabilities.js +3 -0
- package/dist/testing/cross_backend/cell_cross_helpers.d.ts +39 -0
- package/dist/testing/cross_backend/cell_cross_helpers.d.ts.map +1 -0
- package/dist/testing/cross_backend/cell_cross_helpers.js +45 -0
- package/dist/testing/cross_backend/cell_crud.d.ts +4 -0
- package/dist/testing/cross_backend/cell_crud.d.ts.map +1 -0
- package/dist/testing/cross_backend/cell_crud.js +168 -0
- package/dist/testing/cross_backend/cell_relations.d.ts +4 -0
- package/dist/testing/cross_backend/cell_relations.d.ts.map +1 -0
- package/dist/testing/cross_backend/cell_relations.js +229 -0
- package/dist/testing/cross_backend/default_backend_configs.d.ts.map +1 -1
- package/dist/testing/cross_backend/default_backend_configs.js +6 -0
- package/dist/testing/cross_backend/setup.d.ts.map +1 -1
- package/dist/testing/cross_backend/setup.js +5 -0
- package/dist/testing/entities.d.ts.map +1 -1
- package/dist/testing/entities.js +4 -0
- package/dist/testing/ws_round_trip.d.ts.map +1 -1
- package/dist/testing/ws_round_trip.js +4 -0
- package/dist/ui/AdminAccounts.svelte +58 -0
- package/dist/ui/AdminAccounts.svelte.d.ts.map +1 -1
- package/dist/ui/admin_accounts_state.svelte.d.ts +30 -2
- package/dist/ui/admin_accounts_state.svelte.d.ts.map +1 -1
- package/dist/ui/admin_accounts_state.svelte.js +45 -1
- package/dist/ui/admin_rpc_adapters.d.ts +6 -2
- package/dist/ui/admin_rpc_adapters.d.ts.map +1 -1
- package/dist/ui/admin_rpc_adapters.js +5 -1
- package/package.json +2 -2
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Raw queries against the `cell` table.
|
|
3
|
+
*
|
|
4
|
+
* Convention: `deps: QueryDeps` first, no audit side effects, mutations
|
|
5
|
+
* return the affected row (or `null` for not-found).
|
|
6
|
+
*
|
|
7
|
+
* `cell.refs` is auto-extracted from `data` on every create and update via
|
|
8
|
+
* `fact_hash_extract_refs` (depth-first walk for `blake3:`-prefixed strings). Callers
|
|
9
|
+
* never pass `refs` directly — the column is a derived projection of
|
|
10
|
+
* `data` for cells-by-fact discovery, mirroring what a fact store does for
|
|
11
|
+
* JSON facts.
|
|
12
|
+
*
|
|
13
|
+
* Soft delete via `deleted_at`. All `get` / `list` queries exclude
|
|
14
|
+
* tombstones by default; `include_deleted: true` opts in for admin /
|
|
15
|
+
* audit views.
|
|
16
|
+
*
|
|
17
|
+
* `path` uniqueness is global, enforced by `idx_cell_path_unique` (partial
|
|
18
|
+
* on active rows). Path reuse after soft delete falls out of the partial
|
|
19
|
+
* index — queries do not need special handling.
|
|
20
|
+
*
|
|
21
|
+
* @module
|
|
22
|
+
*/
|
|
23
|
+
import { fact_hash_extract_refs } from '@fuzdev/fuz_util/fact_hash.js';
|
|
24
|
+
import { assert_row } from './assert_row.js';
|
|
25
|
+
/**
|
|
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`).
|
|
31
|
+
*
|
|
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.
|
|
35
|
+
*/
|
|
36
|
+
const grant_count_projection = (cell_alias) => `(SELECT COUNT(*)::int FROM cell_grant WHERE cell_id = ${cell_alias}.id) AS grant_count`;
|
|
37
|
+
/**
|
|
38
|
+
* Insert a cell row, deriving `refs` from `data`.
|
|
39
|
+
*
|
|
40
|
+
* `updated_by` is left NULL on insert — same convention as `updated_at`
|
|
41
|
+
* (NULL until first update). The "last modifier" stamp is meaningful only
|
|
42
|
+
* after a real edit; copying the creator's id into `updated_by` at create
|
|
43
|
+
* time would make a no-op update by a different actor look authored by
|
|
44
|
+
* the creator.
|
|
45
|
+
*
|
|
46
|
+
* @param deps - query deps
|
|
47
|
+
* @param input - data, optional visibility, path, and ownership
|
|
48
|
+
* @returns the inserted row
|
|
49
|
+
* @mutates `cell` - inserts one row
|
|
50
|
+
*/
|
|
51
|
+
export const query_cell_create = async (deps, input) => {
|
|
52
|
+
const refs = derive_refs(input.data);
|
|
53
|
+
const row = await deps.db.query_one(`INSERT INTO cell
|
|
54
|
+
(data, visibility, path, refs, created_by)
|
|
55
|
+
VALUES ($1::jsonb, COALESCE($2::cell_visibility, 'private'::cell_visibility), $3, $4::text[], $5)
|
|
56
|
+
RETURNING *, ${grant_count_projection('cell')}`, [
|
|
57
|
+
JSON.stringify(input.data),
|
|
58
|
+
input.visibility ?? null,
|
|
59
|
+
input.path ?? null,
|
|
60
|
+
refs,
|
|
61
|
+
input.created_by ?? null,
|
|
62
|
+
]);
|
|
63
|
+
return assert_row(row, 'INSERT INTO cell');
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* Fetch a cell by id. Excludes soft-deleted rows by default.
|
|
67
|
+
*
|
|
68
|
+
* @param deps - query deps
|
|
69
|
+
* @param id - cell id
|
|
70
|
+
* @param options - `include_deleted: true` returns tombstones
|
|
71
|
+
* @returns the row or `null` when not found (or soft-deleted and not requested)
|
|
72
|
+
*/
|
|
73
|
+
export const query_cell_get = async (deps, id, options) => {
|
|
74
|
+
const include_deleted = options?.include_deleted === true;
|
|
75
|
+
const row = await deps.db.query_one(`SELECT *, ${grant_count_projection('cell')}
|
|
76
|
+
FROM cell
|
|
77
|
+
WHERE id = $1
|
|
78
|
+
AND ($2::bool OR deleted_at IS NULL)`, [id, include_deleted]);
|
|
79
|
+
return row ?? null;
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* Fetch a cell by `path`. Excludes soft-deleted rows; the global partial
|
|
83
|
+
* unique index on `path WHERE deleted_at IS NULL` guarantees at most one
|
|
84
|
+
* result.
|
|
85
|
+
*
|
|
86
|
+
* @param deps - query deps
|
|
87
|
+
* @param path - the named lookup alias (e.g. `/map/main`)
|
|
88
|
+
* @returns the row or `null` when not found
|
|
89
|
+
*/
|
|
90
|
+
export const query_cell_get_by_path = async (deps, path) => {
|
|
91
|
+
const row = await deps.db.query_one(`SELECT *, ${grant_count_projection('cell')}
|
|
92
|
+
FROM cell
|
|
93
|
+
WHERE path = $1 AND deleted_at IS NULL`, [path]);
|
|
94
|
+
return row ?? null;
|
|
95
|
+
};
|
|
96
|
+
/**
|
|
97
|
+
* Bulk-load active cell rows by id, **no visibility filter applied**. Used
|
|
98
|
+
* by the strict relation-read filter (`auth/cell_relation_visibility.ts`'s
|
|
99
|
+
* `filter_visible_target_ids`), which runs `can_view_cell` per row in
|
|
100
|
+
* memory rather than in SQL. Soft-deleted rows are excluded so relations
|
|
101
|
+
* to tombstones never surface.
|
|
102
|
+
*
|
|
103
|
+
* @param deps - query deps
|
|
104
|
+
* @param ids - cell ids to load (duplicates are harmless)
|
|
105
|
+
* @returns active rows in arbitrary order (caller indexes by `id`)
|
|
106
|
+
*/
|
|
107
|
+
export const query_cell_load_many = async (deps, ids) => {
|
|
108
|
+
if (ids.length === 0)
|
|
109
|
+
return [];
|
|
110
|
+
return deps.db.query(`SELECT *, ${grant_count_projection('cell')}
|
|
111
|
+
FROM cell
|
|
112
|
+
WHERE id = ANY($1::uuid[]) AND deleted_at IS NULL`, [ids]);
|
|
113
|
+
};
|
|
114
|
+
/**
|
|
115
|
+
* Update a cell. Fields left `undefined` in the patch keep their existing
|
|
116
|
+
* value; explicit `null` writes `NULL`. `refs` is re-derived from `data`
|
|
117
|
+
* whenever the patch updates `data`. `updated_at` is bumped to `NOW()`
|
|
118
|
+
* on every successful update.
|
|
119
|
+
*
|
|
120
|
+
* @param deps - query deps
|
|
121
|
+
* @param id - cell id
|
|
122
|
+
* @param patch - subset of mutable fields
|
|
123
|
+
* @returns the updated row, or `null` when no row matched (already deleted
|
|
124
|
+
* or never existed)
|
|
125
|
+
* @mutates `cell` - updates one row
|
|
126
|
+
*/
|
|
127
|
+
export const query_cell_update = async (deps, id, patch) => {
|
|
128
|
+
const data_provided = patch.data !== undefined;
|
|
129
|
+
const refs = data_provided ? derive_refs(patch.data) : null;
|
|
130
|
+
const visibility_provided = patch.visibility !== undefined;
|
|
131
|
+
const row = await deps.db.query_one(`UPDATE cell SET
|
|
132
|
+
data = CASE WHEN $2::bool THEN $3::jsonb ELSE data END,
|
|
133
|
+
refs = CASE WHEN $2::bool THEN $4::text[] ELSE refs END,
|
|
134
|
+
path = CASE WHEN $5::bool THEN $6 ELSE path END,
|
|
135
|
+
updated_by = CASE WHEN $7::bool THEN $8 ELSE updated_by END,
|
|
136
|
+
visibility = CASE WHEN $9::bool THEN $10::cell_visibility ELSE visibility END,
|
|
137
|
+
updated_at = NOW()
|
|
138
|
+
WHERE id = $1 AND deleted_at IS NULL
|
|
139
|
+
RETURNING *, ${grant_count_projection('cell')}`, [
|
|
140
|
+
id,
|
|
141
|
+
data_provided,
|
|
142
|
+
data_provided ? JSON.stringify(patch.data) : null,
|
|
143
|
+
refs,
|
|
144
|
+
patch.path !== undefined,
|
|
145
|
+
patch.path ?? null,
|
|
146
|
+
patch.updated_by !== undefined,
|
|
147
|
+
patch.updated_by ?? null,
|
|
148
|
+
visibility_provided,
|
|
149
|
+
patch.visibility ?? null,
|
|
150
|
+
]);
|
|
151
|
+
return row ?? null;
|
|
152
|
+
};
|
|
153
|
+
/**
|
|
154
|
+
* Soft-delete a cell. Sets `deleted_at = NOW()`, `updated_at = NOW()`,
|
|
155
|
+
* and `updated_by = options.deleted_by` (or `NULL`). No-op when the row
|
|
156
|
+
* is already deleted.
|
|
157
|
+
*
|
|
158
|
+
* @param deps - query deps
|
|
159
|
+
* @param id - cell id
|
|
160
|
+
* @param options - `deleted_by` records who triggered the delete
|
|
161
|
+
* @returns `true` when a row was soft-deleted, `false` when no active row matched
|
|
162
|
+
* @mutates `cell` - sets `deleted_at` on one row
|
|
163
|
+
*/
|
|
164
|
+
export const query_cell_delete = async (deps, id, options) => {
|
|
165
|
+
const row = await deps.db.query_one(`UPDATE cell
|
|
166
|
+
SET deleted_at = NOW(),
|
|
167
|
+
updated_at = NOW(),
|
|
168
|
+
updated_by = $2
|
|
169
|
+
WHERE id = $1 AND deleted_at IS NULL
|
|
170
|
+
RETURNING id`, [id, options?.deleted_by ?? null]);
|
|
171
|
+
return row !== undefined;
|
|
172
|
+
};
|
|
173
|
+
/**
|
|
174
|
+
* List cells whose `data.kind` matches the given value, newest first.
|
|
175
|
+
* Uses the `idx_cell_data` GIN index (`data @> ...`).
|
|
176
|
+
*
|
|
177
|
+
* @param deps - query deps
|
|
178
|
+
* @param kind - `data.kind` value to match (e.g. `'collection'`, `'entry'`)
|
|
179
|
+
* @param options - pagination
|
|
180
|
+
* @returns matching active rows
|
|
181
|
+
*/
|
|
182
|
+
export const query_cell_list_by_data_kind = async (deps, kind, options) => deps.db.query(`SELECT *, ${grant_count_projection('cell')}
|
|
183
|
+
FROM cell
|
|
184
|
+
WHERE data @> $1::jsonb
|
|
185
|
+
AND deleted_at IS NULL
|
|
186
|
+
ORDER BY created_at DESC
|
|
187
|
+
LIMIT $2 OFFSET $3`, [JSON.stringify({ kind }), options?.limit ?? null, options?.offset ?? 0]);
|
|
188
|
+
/**
|
|
189
|
+
* List active cells created by an actor, newest first. Backed by the
|
|
190
|
+
* `idx_cell_created_by` partial index.
|
|
191
|
+
*
|
|
192
|
+
* @param deps - query deps
|
|
193
|
+
* @param actor_id - the creator's actor id
|
|
194
|
+
* @param options - pagination
|
|
195
|
+
* @returns matching active rows
|
|
196
|
+
*/
|
|
197
|
+
export const query_cell_list_by_creator = async (deps, actor_id, options) => deps.db.query(`SELECT *, ${grant_count_projection('cell')}
|
|
198
|
+
FROM cell
|
|
199
|
+
WHERE created_by = $1 AND deleted_at IS NULL
|
|
200
|
+
ORDER BY created_at DESC
|
|
201
|
+
LIMIT $2 OFFSET $3`, [actor_id, options?.limit ?? null, options?.offset ?? 0]);
|
|
202
|
+
/**
|
|
203
|
+
* Filterable list query for the generic `cell_list` RPC.
|
|
204
|
+
*
|
|
205
|
+
* Takes a flat filter shape (single optional clause per dimension; the
|
|
206
|
+
* `cell_list` API explicitly does NOT support OR'd alternatives within a
|
|
207
|
+
* dimension — keep it simple) plus an optional viewer-aware visibility
|
|
208
|
+
* predicate.
|
|
209
|
+
*
|
|
210
|
+
* The visibility predicate mirrors `can_view_cell` in SQL form:
|
|
211
|
+
*
|
|
212
|
+
* ```
|
|
213
|
+
* (viewer_is_admin
|
|
214
|
+
* OR cell.visibility = 'public'
|
|
215
|
+
* OR (viewer_actor_id IS NOT NULL AND created_by = viewer_actor_id)
|
|
216
|
+
* OR (viewer_actor_id IS NOT NULL AND <grant admits caller>))
|
|
217
|
+
* ```
|
|
218
|
+
*
|
|
219
|
+
* The grants branch closes parity with `can_view_cell`: a SQL `EXISTS`
|
|
220
|
+
* over `cell_grant`, parameterized by the caller's `actor_id` and the
|
|
221
|
+
* parallel `(role[], scope_id[])` projection of `auth.role_grants`. The
|
|
222
|
+
* caller's role_grants are materialized once via a `caller_role_grants`
|
|
223
|
+
* CTE so the role-grant `unnest` isn't re-scanned per outer row. Empty
|
|
224
|
+
* role_grant arrays are fine: the CTE yields zero rows, the inner EXISTS
|
|
225
|
+
* returns false, and the actor-grant branch still fires for actor-shaped
|
|
226
|
+
* grants.
|
|
227
|
+
*
|
|
228
|
+
* `shared_with_caller_only: true` (`shared_with: 'me'` at the wire layer)
|
|
229
|
+
* takes a **different SQL shape**: instead of layering an extra
|
|
230
|
+
* conjunction on the cell-driven scan, it semi-joins through
|
|
231
|
+
* `cell_grant`, letting the planner drive from the (typically tiny)
|
|
232
|
+
* admitted-grant set via `idx_cell_grant_actor` /
|
|
233
|
+
* `idx_cell_grant_role_scope` rather than scanning every cell row. For a
|
|
234
|
+
* sharee with N grants over a table of M cells, the cost drops from
|
|
235
|
+
* O(M) to O(N + matched-cells). Owner-is-implicit (a cell's owner never
|
|
236
|
+
* appears as a grant principal) means the grants branch is itself
|
|
237
|
+
* owner-excluding, but the explicit `created_by IS DISTINCT FROM caller`
|
|
238
|
+
* guards against any future deviation. The shared_with branch does NOT
|
|
239
|
+
* bypass for admin: an admin asking "what's shared with me" wants their
|
|
240
|
+
* own grant footprint, not every cell.
|
|
241
|
+
*
|
|
242
|
+
* Soft-deleted rows are excluded by default; opt-in via `include_deleted`.
|
|
243
|
+
*
|
|
244
|
+
* @param deps - query deps
|
|
245
|
+
* @param params - filter + visibility + ordering + pagination
|
|
246
|
+
* @returns matching rows, ordered per `order_by` / `order_direction`
|
|
247
|
+
*/
|
|
248
|
+
export const query_cell_list = async (deps, params) => {
|
|
249
|
+
const order_column = params.order_by === 'updated_at' ? 'updated_at' : 'created_at';
|
|
250
|
+
const order_direction = params.order_direction === 'asc' ? 'ASC' : 'DESC';
|
|
251
|
+
// Caller's actor + role_grants feed the `cell_grant` predicate. Empty
|
|
252
|
+
// arrays (not NULL) keep the SQL uniform — the `caller_role_grants` CTE
|
|
253
|
+
// yields zero rows for an empty role_grant set, no special-casing needed.
|
|
254
|
+
const caller_actor_id = params.caller_actor_id ?? null;
|
|
255
|
+
const role_grant_roles = params.caller_role_grant_roles ?? [];
|
|
256
|
+
const role_grant_scope_ids = params.caller_role_grant_scope_ids ?? [];
|
|
257
|
+
// Parallel-array invariant. `unnest(text[], uuid[])` null-pads on
|
|
258
|
+
// length mismatch — a longer roles array would silently widen
|
|
259
|
+
// role-grant admits with NULL `scope_id`s (treated as any-scope by
|
|
260
|
+
// the predicate). Security-relevant; assert at the SQL boundary
|
|
261
|
+
// rather than trusting every caller.
|
|
262
|
+
if (role_grant_roles.length !== role_grant_scope_ids.length) {
|
|
263
|
+
throw new Error(`query_cell_list: caller_role_grant_roles (len=${role_grant_roles.length}) and ` +
|
|
264
|
+
`caller_role_grant_scope_ids (len=${role_grant_scope_ids.length}) must be parallel arrays`);
|
|
265
|
+
}
|
|
266
|
+
const shared_with_caller_only = params.shared_with_caller_only === true;
|
|
267
|
+
// Column references and `$N::type` placeholders are interpolated; user
|
|
268
|
+
// values flow exclusively through the parameterized array below.
|
|
269
|
+
//
|
|
270
|
+
// `starts_with(path, $5)` is used instead of `LIKE $5 || '%'` so caller-
|
|
271
|
+
// supplied wildcards (`%`, `_`, `\`) match literally — Postgres 11+ has
|
|
272
|
+
// the function and pglite/PG 16 supports it.
|
|
273
|
+
//
|
|
274
|
+
// Two SQL shapes share the same 14-param positional layout:
|
|
275
|
+
//
|
|
276
|
+
// - `shared_with_caller_only: false` — cell-driven scan with the
|
|
277
|
+
// visibility predicate (admin / public / owner / grant-admits).
|
|
278
|
+
// - `shared_with_caller_only: true` — grant-driven semi-join: the
|
|
279
|
+
// planner walks `cell_grant` first via partial indexes
|
|
280
|
+
// (`idx_cell_grant_actor`, `idx_cell_grant_role_scope`) and probes
|
|
281
|
+
// `cell` by id, instead of scanning every row in `cell`.
|
|
282
|
+
const sql = shared_with_caller_only
|
|
283
|
+
? build_shared_with_sql(order_column, order_direction)
|
|
284
|
+
: build_general_sql(order_column, order_direction);
|
|
285
|
+
return deps.db.query(sql, [
|
|
286
|
+
params.include_deleted === true,
|
|
287
|
+
params.data_kind ?? null,
|
|
288
|
+
params.ref ?? null,
|
|
289
|
+
params.created_by ?? null,
|
|
290
|
+
params.path_prefix ?? null,
|
|
291
|
+
params.viewer_is_admin,
|
|
292
|
+
params.viewer_actor_id ?? null,
|
|
293
|
+
params.limit ?? null,
|
|
294
|
+
params.offset ?? 0,
|
|
295
|
+
params.ids && params.ids.length > 0 ? params.ids : null,
|
|
296
|
+
caller_actor_id,
|
|
297
|
+
role_grant_roles,
|
|
298
|
+
role_grant_scope_ids,
|
|
299
|
+
params.visibility ?? null,
|
|
300
|
+
]);
|
|
301
|
+
};
|
|
302
|
+
/**
|
|
303
|
+
* The `cell_grant` admits-caller predicate, factored once and reused by
|
|
304
|
+
* both SQL shapes (general visibility branch + shared-with-me semi-join).
|
|
305
|
+
*
|
|
306
|
+
* Resolves true when the grant row at `g_alias` admits the caller via
|
|
307
|
+
* either an actor-shaped principal (`g.actor_id = $11`) or a
|
|
308
|
+
* role-shaped principal whose `(role, scope_id)` matches a row in the
|
|
309
|
+
* `caller_role_grants` CTE. NULL `g.scope_id` matches any scope, mirroring
|
|
310
|
+
* `grant_admits` in `cell_authorize.ts`.
|
|
311
|
+
*/
|
|
312
|
+
const grant_admits_caller_predicate = (g_alias) => `(
|
|
313
|
+
($11::uuid IS NOT NULL AND ${g_alias}.actor_id = $11)
|
|
314
|
+
OR (
|
|
315
|
+
${g_alias}.role IS NOT NULL
|
|
316
|
+
AND EXISTS (
|
|
317
|
+
SELECT 1 FROM caller_role_grants p
|
|
318
|
+
WHERE p.role = ${g_alias}.role
|
|
319
|
+
AND (${g_alias}.scope_id IS NULL OR p.scope_id IS NOT DISTINCT FROM ${g_alias}.scope_id)
|
|
320
|
+
)
|
|
321
|
+
)
|
|
322
|
+
)`;
|
|
323
|
+
/**
|
|
324
|
+
* Materialize the caller's `(role, scope_id)` role_grant pairs once per
|
|
325
|
+
* query so the role-grant predicate doesn't re-scan `unnest()` per
|
|
326
|
+
* outer row. PG 12+ inlines non-recursive single-use CTEs, so this is
|
|
327
|
+
* planner-equivalent to a subquery — the form is for clarity.
|
|
328
|
+
*/
|
|
329
|
+
const CALLER_ROLE_GRANTS_CTE = `WITH caller_role_grants AS (
|
|
330
|
+
SELECT role, scope_id FROM unnest($12::text[], $13::uuid[]) AS p(role, scope_id)
|
|
331
|
+
)`;
|
|
332
|
+
/** General `cell_list` SQL: cell-driven scan, full visibility predicate. */
|
|
333
|
+
const build_general_sql = (order_column, order_direction) => `${CALLER_ROLE_GRANTS_CTE}
|
|
334
|
+
SELECT c.*, ${grant_count_projection('c')} FROM cell c
|
|
335
|
+
WHERE ($1::bool OR c.deleted_at IS NULL)
|
|
336
|
+
AND ($2::text IS NULL OR c.data @> jsonb_build_object('kind', $2::text))
|
|
337
|
+
AND ($14::cell_visibility IS NULL OR c.visibility = $14::cell_visibility)
|
|
338
|
+
AND ($3::text IS NULL OR c.refs @> ARRAY[$3]::text[])
|
|
339
|
+
AND ($4::uuid IS NULL OR c.created_by = $4)
|
|
340
|
+
AND ($5::text IS NULL OR starts_with(c.path, $5))
|
|
341
|
+
AND ($10::uuid[] IS NULL OR c.id = ANY($10))
|
|
342
|
+
AND (
|
|
343
|
+
$6::bool
|
|
344
|
+
OR c.visibility = 'public'
|
|
345
|
+
OR ($7::uuid IS NOT NULL AND c.created_by = $7)
|
|
346
|
+
OR ($7::uuid IS NOT NULL AND EXISTS (
|
|
347
|
+
SELECT 1 FROM cell_grant g
|
|
348
|
+
WHERE g.cell_id = c.id AND ${grant_admits_caller_predicate('g')}
|
|
349
|
+
))
|
|
350
|
+
)
|
|
351
|
+
ORDER BY c.${order_column} ${order_direction} NULLS LAST
|
|
352
|
+
LIMIT $8 OFFSET $9`;
|
|
353
|
+
/**
|
|
354
|
+
* Shared-with-me `cell_list` SQL: grant-driven semi-join. The `IN
|
|
355
|
+
* (SELECT g.cell_id ...)` form lets the planner walk `cell_grant`
|
|
356
|
+
* first via the partial indexes on `actor_id` / `(role, scope_id)`,
|
|
357
|
+
* then probe `cell` by primary key. For a sharee with a few grants
|
|
358
|
+
* over a large `cell` table this is dramatically faster than the
|
|
359
|
+
* cell-driven `EXISTS` form.
|
|
360
|
+
*
|
|
361
|
+
* `$7::uuid IS NOT NULL` is asserted by the handler (anonymous callers
|
|
362
|
+
* are rejected at the action layer), but we re-encode it as
|
|
363
|
+
* `created_by IS DISTINCT FROM $7` so a NULL caller actor (defense in
|
|
364
|
+
* depth) doesn't accidentally admit cells with NULL `created_by`.
|
|
365
|
+
*
|
|
366
|
+
* `$6` (admin bypass) is intentionally not consulted: an admin asking
|
|
367
|
+
* "what's shared with me" wants their grant footprint, not every cell.
|
|
368
|
+
*/
|
|
369
|
+
const build_shared_with_sql = (order_column, order_direction) => `${CALLER_ROLE_GRANTS_CTE}
|
|
370
|
+
SELECT c.*, ${grant_count_projection('c')} FROM cell c
|
|
371
|
+
WHERE ($1::bool OR c.deleted_at IS NULL)
|
|
372
|
+
AND ($2::text IS NULL OR c.data @> jsonb_build_object('kind', $2::text))
|
|
373
|
+
AND ($14::cell_visibility IS NULL OR c.visibility = $14::cell_visibility)
|
|
374
|
+
AND ($3::text IS NULL OR c.refs @> ARRAY[$3]::text[])
|
|
375
|
+
AND ($4::uuid IS NULL OR c.created_by = $4)
|
|
376
|
+
AND ($5::text IS NULL OR starts_with(c.path, $5))
|
|
377
|
+
AND ($10::uuid[] IS NULL OR c.id = ANY($10))
|
|
378
|
+
AND $7::uuid IS NOT NULL
|
|
379
|
+
AND c.created_by IS DISTINCT FROM $7
|
|
380
|
+
AND c.id IN (
|
|
381
|
+
SELECT g.cell_id FROM cell_grant g
|
|
382
|
+
WHERE ${grant_admits_caller_predicate('g')}
|
|
383
|
+
)
|
|
384
|
+
-- $6 (viewer_is_admin) intentionally not consulted: an admin
|
|
385
|
+
-- asking "what's shared with me" wants their grant footprint, not
|
|
386
|
+
-- every cell. Cast-only reference keeps the param's type known to
|
|
387
|
+
-- the planner so the shared 14-param positional layout stays valid.
|
|
388
|
+
AND $6::bool IS NOT NULL
|
|
389
|
+
ORDER BY c.${order_column} ${order_direction} NULLS LAST
|
|
390
|
+
LIMIT $8 OFFSET $9`;
|
|
391
|
+
/**
|
|
392
|
+
* List active cells whose `refs` array contains the given fact hash,
|
|
393
|
+
* newest first. Backed by the `idx_cell_refs` GIN index.
|
|
394
|
+
*
|
|
395
|
+
* Used by the fact-serving route's authz walk: a fact is viewable iff
|
|
396
|
+
* **at least one** referencing active cell admits the caller via
|
|
397
|
+
* `can_view_cell`. Unreferenced facts (no row returned here) are
|
|
398
|
+
* unreachable through the public surface — orphan-fact GC handles them.
|
|
399
|
+
*
|
|
400
|
+
* `include_grant_count` defaults to true so the row hydrates uniformly
|
|
401
|
+
* with the rest of the cell query surface. The fact-serving route is
|
|
402
|
+
* the one hot path where the count is wasted work — pass `false`
|
|
403
|
+
* there to skip the per-row correlated subquery; the field falls back
|
|
404
|
+
* to a constant 0 so `CellRow` stays type-stable.
|
|
405
|
+
*
|
|
406
|
+
* @param deps - query deps
|
|
407
|
+
* @param hash - fact hash to search for
|
|
408
|
+
* @param options - pagination + grant-count toggle
|
|
409
|
+
* @returns matching active rows
|
|
410
|
+
*/
|
|
411
|
+
export const query_cell_list_by_ref = async (deps, hash, options) => {
|
|
412
|
+
const include_grant_count = options?.include_grant_count !== false;
|
|
413
|
+
const projection = include_grant_count ? grant_count_projection('cell') : '0::int AS grant_count';
|
|
414
|
+
return deps.db.query(`SELECT *, ${projection}
|
|
415
|
+
FROM cell
|
|
416
|
+
WHERE refs @> ARRAY[$1]::text[]
|
|
417
|
+
AND deleted_at IS NULL
|
|
418
|
+
ORDER BY created_at DESC
|
|
419
|
+
LIMIT $2 OFFSET $3`, [hash, options?.limit ?? null, options?.offset ?? 0]);
|
|
420
|
+
};
|
|
421
|
+
/**
|
|
422
|
+
* Derive the `refs` array column value from a cell's `data`.
|
|
423
|
+
*
|
|
424
|
+
* Returns `null` (rather than `[]`) when no refs are present — the column
|
|
425
|
+
* is nullable and the `idx_cell_refs` partial index is `WHERE refs IS NOT
|
|
426
|
+
* NULL`, so an empty array would force every cell into the index.
|
|
427
|
+
*/
|
|
428
|
+
const derive_refs = (data) => {
|
|
429
|
+
const refs = fact_hash_extract_refs(data);
|
|
430
|
+
return refs.length > 0 ? refs : null;
|
|
431
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fact + memo PG schema.
|
|
3
|
+
*
|
|
4
|
+
* Three tables:
|
|
5
|
+
*
|
|
6
|
+
* - `facts` — content-addressed bytes. `hash = 'blake3:<hex64>'`. Either
|
|
7
|
+
* embedded (`bytes`) or referenced (`external_url`); the CHECK constraint
|
|
8
|
+
* enforces exactly one populated. Idempotent: same bytes always produce
|
|
9
|
+
* the same hash, so `INSERT … ON CONFLICT DO NOTHING` is the put primitive.
|
|
10
|
+
* - `fact_refs` — declared dependency edges (source fact → target fact).
|
|
11
|
+
* `target_hash` is intentionally **not** a foreign key: in federation a
|
|
12
|
+
* reference may target a fact stored on another instance.
|
|
13
|
+
* - `memos` — `(fn_id, input_hash) → output_hash` for memoized computations.
|
|
14
|
+
*
|
|
15
|
+
* @module
|
|
16
|
+
*/
|
|
17
|
+
import type { Migration, MigrationNamespace } from './migrate.js';
|
|
18
|
+
/** `facts` table — content-addressed byte store. */
|
|
19
|
+
export declare const FACTS_SCHEMA = "\nCREATE TABLE IF NOT EXISTS facts (\n\thash TEXT PRIMARY KEY,\n\tbytes BYTEA,\n\texternal_url TEXT,\n\tcontent_type TEXT,\n\tsize BIGINT NOT NULL,\n\tcreated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n\tCONSTRAINT facts_storage_present CHECK (bytes IS NOT NULL OR external_url IS NOT NULL)\n)";
|
|
20
|
+
/**
|
|
21
|
+
* `fact_refs` table — declared dependency edges between facts.
|
|
22
|
+
*
|
|
23
|
+
* `target_hash` is not a foreign key (federation: target may live remotely).
|
|
24
|
+
*/
|
|
25
|
+
export declare const FACT_REFS_SCHEMA = "\nCREATE TABLE IF NOT EXISTS fact_refs (\n\tsource_hash TEXT NOT NULL REFERENCES facts(hash) ON DELETE CASCADE,\n\ttarget_hash TEXT NOT NULL,\n\tPRIMARY KEY (source_hash, target_hash)\n)";
|
|
26
|
+
/** Reverse lookup: which facts reference a given target? */
|
|
27
|
+
export declare const FACT_REFS_TARGET_INDEX = "\nCREATE INDEX IF NOT EXISTS idx_fact_refs_target ON fact_refs(target_hash)";
|
|
28
|
+
/** `memos` table — `(fn_id, input_hash) → output_hash` for memoized computations. */
|
|
29
|
+
export declare const MEMOS_SCHEMA = "\nCREATE TABLE IF NOT EXISTS memos (\n\tfn_id TEXT NOT NULL,\n\tinput_hash TEXT NOT NULL,\n\toutput_hash TEXT NOT NULL,\n\tcreated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n\tPRIMARY KEY (fn_id, input_hash)\n)";
|
|
30
|
+
/** Tables created by `FACT_MIGRATION_NS`, in drop order (children first). */
|
|
31
|
+
export declare const FACT_DROP_TABLES: readonly ["memos", "fact_refs", "facts"];
|
|
32
|
+
/** Fact + memo migrations. */
|
|
33
|
+
export declare const FACT_MIGRATIONS: Array<Migration>;
|
|
34
|
+
/** Namespace identifier for fact + memo migrations. */
|
|
35
|
+
export declare const FACT_MIGRATION_NAMESPACE = "fuz_facts";
|
|
36
|
+
/** Migration namespace consumed by `run_migrations`. */
|
|
37
|
+
export declare const FACT_MIGRATION_NS: MigrationNamespace;
|
|
38
|
+
//# sourceMappingURL=fact_ddl.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fact_ddl.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/db/fact_ddl.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAGH,OAAO,KAAK,EAAC,SAAS,EAAE,kBAAkB,EAAC,MAAM,cAAc,CAAC;AAEhE,oDAAoD;AACpD,eAAO,MAAM,YAAY,uSASvB,CAAC;AAEH;;;;GAIG;AACH,eAAO,MAAM,gBAAgB,+LAK3B,CAAC;AAEH,4DAA4D;AAC5D,eAAO,MAAM,sBAAsB,gFACuC,CAAC;AAE3E,qFAAqF;AACrF,eAAO,MAAM,YAAY,oNAOvB,CAAC;AAEH,6EAA6E;AAC7E,eAAO,MAAM,gBAAgB,0CAA2C,CAAC;AAEzE,8BAA8B;AAC9B,eAAO,MAAM,eAAe,EAAE,KAAK,CAAC,SAAS,CAU5C,CAAC;AAEF,uDAAuD;AACvD,eAAO,MAAM,wBAAwB,cAAc,CAAC;AAEpD,wDAAwD;AACxD,eAAO,MAAM,iBAAiB,EAAE,kBAG/B,CAAC"}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fact + memo PG schema.
|
|
3
|
+
*
|
|
4
|
+
* Three tables:
|
|
5
|
+
*
|
|
6
|
+
* - `facts` — content-addressed bytes. `hash = 'blake3:<hex64>'`. Either
|
|
7
|
+
* embedded (`bytes`) or referenced (`external_url`); the CHECK constraint
|
|
8
|
+
* enforces exactly one populated. Idempotent: same bytes always produce
|
|
9
|
+
* the same hash, so `INSERT … ON CONFLICT DO NOTHING` is the put primitive.
|
|
10
|
+
* - `fact_refs` — declared dependency edges (source fact → target fact).
|
|
11
|
+
* `target_hash` is intentionally **not** a foreign key: in federation a
|
|
12
|
+
* reference may target a fact stored on another instance.
|
|
13
|
+
* - `memos` — `(fn_id, input_hash) → output_hash` for memoized computations.
|
|
14
|
+
*
|
|
15
|
+
* @module
|
|
16
|
+
*/
|
|
17
|
+
/** `facts` table — content-addressed byte store. */
|
|
18
|
+
export const FACTS_SCHEMA = `
|
|
19
|
+
CREATE TABLE IF NOT EXISTS facts (
|
|
20
|
+
hash TEXT PRIMARY KEY,
|
|
21
|
+
bytes BYTEA,
|
|
22
|
+
external_url TEXT,
|
|
23
|
+
content_type TEXT,
|
|
24
|
+
size BIGINT NOT NULL,
|
|
25
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
26
|
+
CONSTRAINT facts_storage_present CHECK (bytes IS NOT NULL OR external_url IS NOT NULL)
|
|
27
|
+
)`;
|
|
28
|
+
/**
|
|
29
|
+
* `fact_refs` table — declared dependency edges between facts.
|
|
30
|
+
*
|
|
31
|
+
* `target_hash` is not a foreign key (federation: target may live remotely).
|
|
32
|
+
*/
|
|
33
|
+
export const FACT_REFS_SCHEMA = `
|
|
34
|
+
CREATE TABLE IF NOT EXISTS fact_refs (
|
|
35
|
+
source_hash TEXT NOT NULL REFERENCES facts(hash) ON DELETE CASCADE,
|
|
36
|
+
target_hash TEXT NOT NULL,
|
|
37
|
+
PRIMARY KEY (source_hash, target_hash)
|
|
38
|
+
)`;
|
|
39
|
+
/** Reverse lookup: which facts reference a given target? */
|
|
40
|
+
export const FACT_REFS_TARGET_INDEX = `
|
|
41
|
+
CREATE INDEX IF NOT EXISTS idx_fact_refs_target ON fact_refs(target_hash)`;
|
|
42
|
+
/** `memos` table — `(fn_id, input_hash) → output_hash` for memoized computations. */
|
|
43
|
+
export const MEMOS_SCHEMA = `
|
|
44
|
+
CREATE TABLE IF NOT EXISTS memos (
|
|
45
|
+
fn_id TEXT NOT NULL,
|
|
46
|
+
input_hash TEXT NOT NULL,
|
|
47
|
+
output_hash TEXT NOT NULL,
|
|
48
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
49
|
+
PRIMARY KEY (fn_id, input_hash)
|
|
50
|
+
)`;
|
|
51
|
+
/** Tables created by `FACT_MIGRATION_NS`, in drop order (children first). */
|
|
52
|
+
export const FACT_DROP_TABLES = ['memos', 'fact_refs', 'facts'];
|
|
53
|
+
/** Fact + memo migrations. */
|
|
54
|
+
export const FACT_MIGRATIONS = [
|
|
55
|
+
{
|
|
56
|
+
name: 'facts_v0',
|
|
57
|
+
up: async (db) => {
|
|
58
|
+
await db.query(FACTS_SCHEMA);
|
|
59
|
+
await db.query(FACT_REFS_SCHEMA);
|
|
60
|
+
await db.query(FACT_REFS_TARGET_INDEX);
|
|
61
|
+
await db.query(MEMOS_SCHEMA);
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
];
|
|
65
|
+
/** Namespace identifier for fact + memo migrations. */
|
|
66
|
+
export const FACT_MIGRATION_NAMESPACE = 'fuz_facts';
|
|
67
|
+
/** Migration namespace consumed by `run_migrations`. */
|
|
68
|
+
export const FACT_MIGRATION_NS = {
|
|
69
|
+
namespace: FACT_MIGRATION_NAMESPACE,
|
|
70
|
+
migrations: FACT_MIGRATIONS,
|
|
71
|
+
};
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Raw queries against the `facts` and `fact_refs` tables.
|
|
3
|
+
*
|
|
4
|
+
* Convention: `deps: QueryDeps` first, no audit side effects, mutations are
|
|
5
|
+
* idempotent (`ON CONFLICT DO NOTHING`) so the same hash can be written by
|
|
6
|
+
* two callers without the second observing an error.
|
|
7
|
+
*
|
|
8
|
+
* Higher-level lifecycle (verify-on-read, JSON ref auto-extraction,
|
|
9
|
+
* embedded-vs-referenced selection) lives in `db/fact_store.ts`. Queries
|
|
10
|
+
* here are deliberately mechanical.
|
|
11
|
+
*
|
|
12
|
+
* @module
|
|
13
|
+
*/
|
|
14
|
+
import type { QueryDeps } from './query_deps.js';
|
|
15
|
+
import type { FactHash } from '@fuzdev/fuz_util/fact_hash.js';
|
|
16
|
+
/** Row shape for `SELECT … FROM facts`. */
|
|
17
|
+
export interface FactRow {
|
|
18
|
+
hash: FactHash;
|
|
19
|
+
bytes: Uint8Array | null;
|
|
20
|
+
external_url: string | null;
|
|
21
|
+
content_type: string | null;
|
|
22
|
+
size: number | string;
|
|
23
|
+
created_at: Date;
|
|
24
|
+
}
|
|
25
|
+
/** Subset returned by metadata-only queries (no `bytes` payload). */
|
|
26
|
+
export interface FactMetaRow {
|
|
27
|
+
hash: FactHash;
|
|
28
|
+
external_url: string | null;
|
|
29
|
+
content_type: string | null;
|
|
30
|
+
size: number | string;
|
|
31
|
+
created_at: Date;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Idempotently insert a fact row.
|
|
35
|
+
*
|
|
36
|
+
* `bytes` xor `external_url` per the `facts_storage_present` CHECK
|
|
37
|
+
* constraint; the caller is responsible for satisfying it (the queries
|
|
38
|
+
* layer does not second-guess). Returns `true` when a new row was
|
|
39
|
+
* inserted, `false` when a row already existed (caller can use this to
|
|
40
|
+
* decide whether to also write `fact_refs`).
|
|
41
|
+
*/
|
|
42
|
+
export declare const query_put_fact: (deps: QueryDeps, input: {
|
|
43
|
+
hash: FactHash;
|
|
44
|
+
bytes: Uint8Array | null;
|
|
45
|
+
external_url: string | null;
|
|
46
|
+
content_type: string | null;
|
|
47
|
+
size: number;
|
|
48
|
+
}) => Promise<boolean>;
|
|
49
|
+
/**
|
|
50
|
+
* Idempotently insert declared refs for a fact. No-ops on `(source_hash,
|
|
51
|
+
* target_hash)` collisions and skips the round trip entirely when
|
|
52
|
+
* `target_hashes` is empty.
|
|
53
|
+
*/
|
|
54
|
+
export declare const query_put_fact_refs: (deps: QueryDeps, source_hash: FactHash, target_hashes: Array<FactHash>) => Promise<void>;
|
|
55
|
+
/**
|
|
56
|
+
* Fetch a fact's full row (including embedded `bytes`). Use this from
|
|
57
|
+
* `FactStore.get`; cheaper accessors live below.
|
|
58
|
+
*/
|
|
59
|
+
export declare const query_get_fact: (deps: QueryDeps, hash: FactHash) => Promise<FactRow | null>;
|
|
60
|
+
/**
|
|
61
|
+
* Fetch metadata only — skips the (potentially large) `bytes` column.
|
|
62
|
+
*/
|
|
63
|
+
export declare const query_get_fact_meta: (deps: QueryDeps, hash: FactHash) => Promise<FactMetaRow | null>;
|
|
64
|
+
/**
|
|
65
|
+
* Cheap existence check. Backed by the `facts` PK index.
|
|
66
|
+
*/
|
|
67
|
+
export declare const query_has_fact: (deps: QueryDeps, hash: FactHash) => Promise<boolean>;
|
|
68
|
+
/**
|
|
69
|
+
* List declared targets for a source fact. Order is unspecified; callers
|
|
70
|
+
* that need stable ordering should sort.
|
|
71
|
+
*/
|
|
72
|
+
export declare const query_get_fact_refs: (deps: QueryDeps, source_hash: FactHash) => Promise<Array<FactHash>>;
|
|
73
|
+
/**
|
|
74
|
+
* Drop a fact row. Cascades `fact_refs` rows via the `ON DELETE CASCADE`
|
|
75
|
+
* FK on `source_hash`. Returns the deleted row's `(size, external_url)`
|
|
76
|
+
* so the caller can unlink the disk file (if any) and tally freed bytes,
|
|
77
|
+
* or `null` when no row matched (idempotent: deleting an absent fact is
|
|
78
|
+
* not an error).
|
|
79
|
+
*
|
|
80
|
+
* NOTE: this is a low-level primitive — callers MUST verify the fact is
|
|
81
|
+
* truly orphan (no referencing cell) before calling. The orphan check
|
|
82
|
+
* lives in `query_orphan_facts_*` below; the lifecycle wrapper in
|
|
83
|
+
* `PgFactStore.delete` handles the disk-file unlink.
|
|
84
|
+
*/
|
|
85
|
+
export declare const query_delete_fact: (deps: QueryDeps, hash: FactHash) => Promise<{
|
|
86
|
+
size: number;
|
|
87
|
+
external_url: string | null;
|
|
88
|
+
} | null>;
|
|
89
|
+
/**
|
|
90
|
+
* Summary + sample shape returned by `query_orphan_facts_list`. The sample
|
|
91
|
+
* is a small page (default 20 rows) shown in the admin panel so the
|
|
92
|
+
* operator has *some* visibility into what they're about to delete.
|
|
93
|
+
* Total `count` and `total_size_bytes` are over the full orphan set
|
|
94
|
+
* (matching the same predicate the delete handler will run).
|
|
95
|
+
*/
|
|
96
|
+
export interface OrphanFactsListResult {
|
|
97
|
+
count: number;
|
|
98
|
+
total_size_bytes: number;
|
|
99
|
+
sample: Array<{
|
|
100
|
+
hash: FactHash;
|
|
101
|
+
size: number;
|
|
102
|
+
created_at: string;
|
|
103
|
+
external_url: string | null;
|
|
104
|
+
}>;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Compute the "orphan facts" set: rows in `facts` where no active
|
|
108
|
+
* (non-tombstone) `cell.refs` array contains the hash.
|
|
109
|
+
*
|
|
110
|
+
* The `cell` join is deliberately app-coupled — `facts` lives in the
|
|
111
|
+
* `fuz_facts` namespace and `cell.refs` lives in `fuz_cell`, but the
|
|
112
|
+
* orphan predicate only makes sense in apps that route content through
|
|
113
|
+
* cells. When a non-cell fact consumer ever appears (signed memo
|
|
114
|
+
* outputs? external fact mirrors?) the predicate moves to a generic
|
|
115
|
+
* `fact_consumers` registry; today the cell layer is the only consumer.
|
|
116
|
+
*
|
|
117
|
+
* The `older_than` filter applies to `facts.created_at`. Pass `null`
|
|
118
|
+
* to skip the filter (used by the list-summary preview); the delete
|
|
119
|
+
* handler always passes a non-null cutoff (default 0, meaning "any
|
|
120
|
+
* orphan").
|
|
121
|
+
*
|
|
122
|
+
* @param deps - query deps
|
|
123
|
+
* @param older_than - filter to facts created before this Date (or null
|
|
124
|
+
* to skip)
|
|
125
|
+
* @param sample_limit - row cap for the returned `sample`
|
|
126
|
+
*/
|
|
127
|
+
export declare const query_orphan_facts_list: (deps: QueryDeps, older_than: Date | null, sample_limit: number) => Promise<OrphanFactsListResult>;
|
|
128
|
+
/**
|
|
129
|
+
* Select the orphan-fact hashes for deletion. Returns the rows directly
|
|
130
|
+
* (no row-count limit) — callers iterate to unlink disk files. The
|
|
131
|
+
* `older_than` cutoff is required (non-null) here: bulk delete should
|
|
132
|
+
* always be operator-scoped to a time window. A "delete all" sweep
|
|
133
|
+
* passes a far-future cutoff, not `null`.
|
|
134
|
+
*/
|
|
135
|
+
export declare const query_orphan_facts_select_for_delete: (deps: QueryDeps, older_than: Date) => Promise<Array<{
|
|
136
|
+
hash: FactHash;
|
|
137
|
+
size: number;
|
|
138
|
+
external_url: string | null;
|
|
139
|
+
}>>;
|
|
140
|
+
//# sourceMappingURL=fact_queries.d.ts.map
|