@fuzdev/fuz_app 0.143.0 → 0.145.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 (56) hide show
  1. package/dist/auth/CLAUDE.md +8 -0
  2. package/dist/auth/cell_action_specs.d.ts +13 -0
  3. package/dist/auth/cell_action_specs.d.ts.map +1 -1
  4. package/dist/auth/cell_action_specs.js +13 -0
  5. package/dist/auth/cell_actions.d.ts +7 -0
  6. package/dist/auth/cell_actions.d.ts.map +1 -1
  7. package/dist/auth/cell_actions.js +30 -0
  8. package/dist/auth/cell_ref_admission.d.ts +51 -0
  9. package/dist/auth/cell_ref_admission.d.ts.map +1 -0
  10. package/dist/auth/cell_ref_admission.js +69 -0
  11. package/dist/auth/deps.d.ts +6 -3
  12. package/dist/auth/deps.d.ts.map +1 -1
  13. package/dist/db/CLAUDE.md +40 -23
  14. package/dist/db/cell_ddl.d.ts +3 -2
  15. package/dist/db/cell_ddl.d.ts.map +1 -1
  16. package/dist/db/cell_ddl.js +3 -2
  17. package/dist/db/cell_queries.d.ts +31 -6
  18. package/dist/db/cell_queries.d.ts.map +1 -1
  19. package/dist/db/cell_queries.js +45 -18
  20. package/dist/db/fact_ddl.d.ts +30 -4
  21. package/dist/db/fact_ddl.d.ts.map +1 -1
  22. package/dist/db/fact_ddl.js +44 -4
  23. package/dist/db/fact_holder_queries.d.ts +70 -0
  24. package/dist/db/fact_holder_queries.d.ts.map +1 -0
  25. package/dist/db/fact_holder_queries.js +79 -0
  26. package/dist/db/fact_queries.d.ts +19 -14
  27. package/dist/db/fact_queries.d.ts.map +1 -1
  28. package/dist/db/fact_queries.js +31 -10
  29. package/dist/db/fact_store.d.ts +65 -9
  30. package/dist/db/fact_store.d.ts.map +1 -1
  31. package/dist/db/fact_store.js +49 -20
  32. package/dist/server/fact_security_headers.d.ts +54 -0
  33. package/dist/server/fact_security_headers.d.ts.map +1 -0
  34. package/dist/server/fact_security_headers.js +53 -0
  35. package/dist/server/serve_fact_route.d.ts +33 -7
  36. package/dist/server/serve_fact_route.d.ts.map +1 -1
  37. package/dist/server/serve_fact_route.js +36 -32
  38. package/dist/server/x_accel.d.ts +106 -30
  39. package/dist/server/x_accel.d.ts.map +1 -1
  40. package/dist/server/x_accel.js +533 -86
  41. package/dist/testing/CLAUDE.md +25 -1
  42. package/dist/testing/cross_backend/cell_crud.d.ts.map +1 -1
  43. package/dist/testing/cross_backend/cell_crud.js +6 -3
  44. package/dist/testing/cross_backend/cell_gated_create.d.ts.map +1 -1
  45. package/dist/testing/cross_backend/cell_gated_create.js +25 -16
  46. package/dist/testing/cross_backend/cell_ref_admission.d.ts +4 -0
  47. package/dist/testing/cross_backend/cell_ref_admission.d.ts.map +1 -0
  48. package/dist/testing/cross_backend/cell_ref_admission.js +268 -0
  49. package/dist/testing/cross_backend/expected_schema.json +66 -10
  50. package/dist/testing/cross_backend/fact_serving.d.ts +19 -1
  51. package/dist/testing/cross_backend/fact_serving.d.ts.map +1 -1
  52. package/dist/testing/cross_backend/fact_serving.js +31 -9
  53. package/dist/testing/cross_backend/testing_reset_actions.d.ts +6 -2
  54. package/dist/testing/cross_backend/testing_reset_actions.d.ts.map +1 -1
  55. package/dist/testing/cross_backend/testing_reset_actions.js +13 -3
  56. package/package.json +3 -3
@@ -4,11 +4,11 @@
4
4
  * Convention: `deps: QueryDeps` first, no audit side effects, mutations
5
5
  * return the affected row (or `null` for not-found).
6
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.
7
+ * `cell.refs` is derived from `data` on every create and update via
8
+ * `derive_cell_refs` (depth-first walk for string values that are exactly a
9
+ * fact hash). Callers never pass `refs` directly — the column is a derived
10
+ * projection of `data`, what fact serving admits a read through and what
11
+ * cell ref admission (`auth/cell_ref_admission.ts`) gates on a write.
12
12
  *
13
13
  * Soft delete via `deleted_at`. All `get` / `list` queries exclude
14
14
  * tombstones by default; `include_deleted: true` opts in for admin /
@@ -20,7 +20,7 @@
20
20
  *
21
21
  * @module
22
22
  */
23
- import { fact_hash_extract_refs } from '@fuzdev/fuz_util/fact_hash.ts';
23
+ import { is_fact_hash } from '@fuzdev/fuz_util/hash_schemas.ts';
24
24
  import { assert_row } from './assert_row.js';
25
25
  import { columns_sql, iso8601_timestamp_expr, qualify_columns } from './sql_columns.js';
26
26
  import { cell_moderation_admits_audience_sql, cell_moderation_is_stewardable_sql } from './cell_moderation.js';
@@ -98,7 +98,7 @@ export const CELL_VIEW_COLUMNS = [
98
98
  * @mutates `cell` - inserts one row
99
99
  */
100
100
  export const query_cell_create = async (deps, input) => {
101
- const refs = derive_refs(input.data);
101
+ const refs = derive_cell_refs(input.data);
102
102
  const row = await deps.db.query_one(`INSERT INTO cell
103
103
  (data, kind, visibility, path, refs, parent_id, root_id, moderation, created_by)
104
104
  VALUES ($1::jsonb, $2, COALESCE($3::cell_visibility, 'private'::cell_visibility), $4, $5::text[], $6, $7, $8, $9)
@@ -196,7 +196,7 @@ export const query_cell_view_load_many = async (deps, ids) => {
196
196
  */
197
197
  export const query_cell_update = async (deps, id, patch) => {
198
198
  const data_provided = patch.data !== undefined;
199
- const refs = data_provided ? derive_refs(patch.data) : null;
199
+ const refs = data_provided ? derive_cell_refs(patch.data) : null;
200
200
  const visibility_provided = patch.visibility !== undefined;
201
201
  const row = await deps.db.query_one(`UPDATE cell SET
202
202
  data = CASE WHEN $2::bool THEN $3::jsonb ELSE data END,
@@ -485,20 +485,47 @@ const build_shared_with_sql = (order_column, order_direction) => `${CALLER_ROLE_
485
485
  ORDER BY c.${order_column} ${order_direction} NULLS LAST
486
486
  LIMIT $8 OFFSET $9`;
487
487
  /**
488
- * Derive the `refs` array column value from a cell's `data`.
488
+ * Derive the `refs` array column value from a cell's `data`: the string
489
+ * values that are exactly a fact hash, deduped and sorted.
490
+ *
491
+ * A ref is a **whole** string value matching the wire fact-hash grammar —
492
+ * `blake3:` followed by exactly sixty-four lowercase hex characters
493
+ * (`is_fact_hash`, `FACT_HASH_EXACT`). A hash inside prose or markup, an
494
+ * uppercase-hex digest, or a `blake3:` string of any other length is not a
495
+ * ref. Refs are what fact serving admits a read through and what cell ref
496
+ * admission gates on a write, so the grammar is exact: a string either is an
497
+ * address or isn't one. Object keys are not walked — only values. (JSON facts'
498
+ * `fact_ref` edges keep `fact_hash_extract_refs`, the substring scan.)
489
499
  *
490
500
  * Returns `null` (rather than `[]`) when no refs are present — the column
491
501
  * is nullable and the `idx_cell_refs` partial index is `WHERE refs IS NOT
492
502
  * NULL`, so an empty array would force every cell into the index.
493
503
  *
494
- * Sorts the deduped refs lexicographically so the stored `text[]` is
495
- * deterministic and byte-identical to the Rust twin (`fuz_cell`'s
496
- * `extract_refs`, which sorts the same `blake3:`-prefixed element form).
497
- * The shared `fact_hash_extract_refs` deliberately preserves traversal
498
- * order per its contract, so the sort lives here at the storage boundary,
499
- * not in the util.
504
+ * Sorts the deduped refs so the stored `text[]` is deterministic and
505
+ * byte-identical to the Rust twin (`fuz_cell`'s `extract_refs`); every ref is
506
+ * ASCII, so the code-unit sort here and the byte sort there agree.
507
+ *
508
+ * @param data - the cell's `data`
509
+ * @returns the sorted refs, or `null` when there are none
500
510
  */
501
- const derive_refs = (data) => {
502
- const refs = fact_hash_extract_refs(data);
503
- return refs.length > 0 ? refs.sort() : null;
511
+ export const derive_cell_refs = (data) => {
512
+ const found = new Set();
513
+ collect_cell_refs(data, found);
514
+ return found.size > 0 ? [...found].sort() : null;
515
+ };
516
+ const collect_cell_refs = (value, found) => {
517
+ if (typeof value === 'string') {
518
+ if (is_fact_hash(value))
519
+ found.add(value);
520
+ return;
521
+ }
522
+ if (value === null || typeof value !== 'object')
523
+ return;
524
+ if (Array.isArray(value)) {
525
+ for (const item of value)
526
+ collect_cell_refs(item, found);
527
+ return;
528
+ }
529
+ for (const v of Object.values(value))
530
+ collect_cell_refs(v, found);
504
531
  };
@@ -1,7 +1,7 @@
1
1
  /**
2
- * Fact + memo PG schema.
2
+ * Fact + memo + possession PG schema.
3
3
  *
4
- * Three tables:
4
+ * Tables:
5
5
  *
6
6
  * - `fact` — content-addressed bytes. `hash = 'blake3:<hex64>'`. Either
7
7
  * embedded (`bytes`) or referenced (`external_url`); the CHECK constraint
@@ -11,6 +11,20 @@
11
11
  * `target_hash` is intentionally **not** a foreign key: in federation a
12
12
  * reference may target a fact stored on another instance.
13
13
  * - `memo` — `(fn_id, input_hash) → output_hash` for memoized computations.
14
+ * - `fact_holder` — which actors hold which facts: one row per `(actor, fact)`
15
+ * the actor has put, the possession record cell ref admission reads.
16
+ *
17
+ * `fact_holder.actor_id` references the auth schema's `actor` table, so the
18
+ * namespace runs after `fuz_auth`.
19
+ *
20
+ * **The released chain is frozen — every schema change ships as an appended
21
+ * migration.** Migrations are tracked by `(namespace, name)` with no checksum,
22
+ * so editing a released entry in place never reruns on a database that already
23
+ * recorded it. Never edit, rename, reorder, or re-purpose an entry in
24
+ * `FACT_MIGRATIONS`; append a new one with idempotent DDL. Each entry is
25
+ * twinned with the Rust `fuz_fact` migration of the same name and position,
26
+ * statement for statement (the `_testing_migration_tracker` and schema-parity
27
+ * gates compare the two).
14
28
  *
15
29
  * @module
16
30
  */
@@ -27,9 +41,21 @@ export declare const FACT_REFS_SCHEMA = "\nCREATE TABLE IF NOT EXISTS fact_ref (
27
41
  export declare const FACT_REFS_TARGET_INDEX = "\nCREATE INDEX IF NOT EXISTS idx_fact_ref_target ON fact_ref(target_hash)";
28
42
  /** `memo` table — `(fn_id, input_hash) → output_hash` for memoized computations. */
29
43
  export declare const MEMOS_SCHEMA = "\nCREATE TABLE IF NOT EXISTS memo (\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)";
44
+ /**
45
+ * `fact_holder` table — the possession record: one row per `(actor, fact)` the
46
+ * actor has put, a deduplicated put by a second actor included.
47
+ *
48
+ * Both FKs cascade: deleting an actor erases its possession, and deleting a
49
+ * fact (the orphan sweep) drops its holders. The primary key leads with
50
+ * `actor_id`, serving the admission lookup and the actor-side cascade.
51
+ * `created_at` is the holder's most recent put, not the first.
52
+ */
53
+ export declare const FACT_HOLDER_SCHEMA = "\nCREATE TABLE IF NOT EXISTS fact_holder (\n\tactor_id UUID NOT NULL REFERENCES actor(id) ON DELETE CASCADE,\n\thash TEXT NOT NULL REFERENCES fact(hash) ON DELETE CASCADE,\n\tcreated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n\tPRIMARY KEY (actor_id, hash)\n)";
54
+ /** Serves the fact-side cascade of `fact_holder`. */
55
+ export declare const FACT_HOLDER_HASH_INDEX = "\nCREATE INDEX IF NOT EXISTS idx_fact_holder_hash ON fact_holder(hash)";
30
56
  /** Tables created by `FACT_MIGRATION_NS`, in drop order (children first). */
31
- export declare const FACT_DROP_TABLES: readonly ["memo", "fact_ref", "fact"];
32
- /** Fact + memo migrations. */
57
+ export declare const FACT_DROP_TABLES: readonly ["fact_holder", "memo", "fact_ref", "fact"];
58
+ /** Fact + memo + possession migrations. Frozen + append-only — see the module doc. */
33
59
  export declare const FACT_MIGRATIONS: Array<Migration>;
34
60
  /** Namespace identifier for fact + memo migrations. */
35
61
  export declare const FACT_MIGRATION_NAMESPACE = "fuz_facts";
@@ -1 +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,EAAE,SAAS,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAElE,mDAAmD;AACnD,eAAO,MAAM,YAAY,qSASvB,CAAC;AAEH;;;;GAIG;AACH,eAAO,MAAM,gBAAgB,6LAK3B,CAAC;AAEH,4DAA4D;AAC5D,eAAO,MAAM,sBAAsB,8EACqC,CAAC;AAEzE,oFAAoF;AACpF,eAAO,MAAM,YAAY,mNAOvB,CAAC;AAEH,6EAA6E;AAC7E,eAAO,MAAM,gBAAgB,uCAAwC,CAAC;AAEtE,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"}
1
+ {"version":3,"file":"fact_ddl.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/db/fact_ddl.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAGH,OAAO,KAAK,EAAE,SAAS,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAElE,mDAAmD;AACnD,eAAO,MAAM,YAAY,qSASvB,CAAC;AAEH;;;;GAIG;AACH,eAAO,MAAM,gBAAgB,6LAK3B,CAAC;AAEH,4DAA4D;AAC5D,eAAO,MAAM,sBAAsB,8EACqC,CAAC;AAEzE,oFAAoF;AACpF,eAAO,MAAM,YAAY,mNAOvB,CAAC;AAEH;;;;;;;;GAQG;AACH,eAAO,MAAM,kBAAkB,qQAM7B,CAAC;AAEH,qDAAqD;AACrD,eAAO,MAAM,sBAAsB,2EACkC,CAAC;AAEtE,6EAA6E;AAC7E,eAAO,MAAM,gBAAgB,sDAAuD,CAAC;AAErF,sFAAsF;AACtF,eAAO,MAAM,eAAe,EAAE,KAAK,CAAC,SAAS,CAiB5C,CAAC;AAEF,uDAAuD;AACvD,eAAO,MAAM,wBAAwB,cAAc,CAAC;AAEpD,wDAAwD;AACxD,eAAO,MAAM,iBAAiB,EAAE,kBAG/B,CAAC"}
@@ -1,7 +1,7 @@
1
1
  /**
2
- * Fact + memo PG schema.
2
+ * Fact + memo + possession PG schema.
3
3
  *
4
- * Three tables:
4
+ * Tables:
5
5
  *
6
6
  * - `fact` — content-addressed bytes. `hash = 'blake3:<hex64>'`. Either
7
7
  * embedded (`bytes`) or referenced (`external_url`); the CHECK constraint
@@ -11,6 +11,20 @@
11
11
  * `target_hash` is intentionally **not** a foreign key: in federation a
12
12
  * reference may target a fact stored on another instance.
13
13
  * - `memo` — `(fn_id, input_hash) → output_hash` for memoized computations.
14
+ * - `fact_holder` — which actors hold which facts: one row per `(actor, fact)`
15
+ * the actor has put, the possession record cell ref admission reads.
16
+ *
17
+ * `fact_holder.actor_id` references the auth schema's `actor` table, so the
18
+ * namespace runs after `fuz_auth`.
19
+ *
20
+ * **The released chain is frozen — every schema change ships as an appended
21
+ * migration.** Migrations are tracked by `(namespace, name)` with no checksum,
22
+ * so editing a released entry in place never reruns on a database that already
23
+ * recorded it. Never edit, rename, reorder, or re-purpose an entry in
24
+ * `FACT_MIGRATIONS`; append a new one with idempotent DDL. Each entry is
25
+ * twinned with the Rust `fuz_fact` migration of the same name and position,
26
+ * statement for statement (the `_testing_migration_tracker` and schema-parity
27
+ * gates compare the two).
14
28
  *
15
29
  * @module
16
30
  */
@@ -48,9 +62,28 @@ CREATE TABLE IF NOT EXISTS memo (
48
62
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
49
63
  PRIMARY KEY (fn_id, input_hash)
50
64
  )`;
65
+ /**
66
+ * `fact_holder` table — the possession record: one row per `(actor, fact)` the
67
+ * actor has put, a deduplicated put by a second actor included.
68
+ *
69
+ * Both FKs cascade: deleting an actor erases its possession, and deleting a
70
+ * fact (the orphan sweep) drops its holders. The primary key leads with
71
+ * `actor_id`, serving the admission lookup and the actor-side cascade.
72
+ * `created_at` is the holder's most recent put, not the first.
73
+ */
74
+ export const FACT_HOLDER_SCHEMA = `
75
+ CREATE TABLE IF NOT EXISTS fact_holder (
76
+ actor_id UUID NOT NULL REFERENCES actor(id) ON DELETE CASCADE,
77
+ hash TEXT NOT NULL REFERENCES fact(hash) ON DELETE CASCADE,
78
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
79
+ PRIMARY KEY (actor_id, hash)
80
+ )`;
81
+ /** Serves the fact-side cascade of `fact_holder`. */
82
+ export const FACT_HOLDER_HASH_INDEX = `
83
+ CREATE INDEX IF NOT EXISTS idx_fact_holder_hash ON fact_holder(hash)`;
51
84
  /** Tables created by `FACT_MIGRATION_NS`, in drop order (children first). */
52
- export const FACT_DROP_TABLES = ['memo', 'fact_ref', 'fact'];
53
- /** Fact + memo migrations. */
85
+ export const FACT_DROP_TABLES = ['fact_holder', 'memo', 'fact_ref', 'fact'];
86
+ /** Fact + memo + possession migrations. Frozen + append-only — see the module doc. */
54
87
  export const FACT_MIGRATIONS = [
55
88
  {
56
89
  name: 'full_fact_schema',
@@ -60,6 +93,13 @@ export const FACT_MIGRATIONS = [
60
93
  await db.query(FACT_REFS_TARGET_INDEX);
61
94
  await db.query(MEMOS_SCHEMA);
62
95
  }
96
+ },
97
+ {
98
+ name: 'fact_holder',
99
+ up: async (db) => {
100
+ await db.query(FACT_HOLDER_SCHEMA);
101
+ await db.query(FACT_HOLDER_HASH_INDEX);
102
+ }
63
103
  }
64
104
  ];
65
105
  /** Namespace identifier for fact + memo migrations. */
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Raw queries against `fact_holder` — the possession record.
3
+ *
4
+ * A `fact_holder` row says an actor has put a fact's bytes. `PgFactStore`
5
+ * writes one on every put that names a holder (`HeldFactPutOptions.holder`),
6
+ * deduplicated puts included: identical bytes put again by a second actor
7
+ * make that actor a holder too. Cell ref admission
8
+ * (`auth/cell_ref_admission.ts`) reads it back — a write may introduce a fact
9
+ * ref only for a fact the acting actor holds.
10
+ *
11
+ * Same discipline as `db/fact_queries.ts`: `deps: QueryDeps` first (the caller
12
+ * owns the transaction), no audit side effects, idempotent writes.
13
+ *
14
+ * @module
15
+ */
16
+ import type { FactHash } from '@fuzdev/fuz_util/hash_schemas.ts';
17
+ import type { QueryDeps } from './query_deps.js';
18
+ import type { Uuid } from '../primitive_schemas.js';
19
+ /** Row shape of `fact_holder`. */
20
+ export interface FactHolderRow {
21
+ actor_id: Uuid;
22
+ hash: FactHash;
23
+ created_at: Date;
24
+ }
25
+ /**
26
+ * The `fact_holder` table's columns.
27
+ *
28
+ * Twin of the Rust `fuz_fact::FACT_HOLDER_COLUMNS` (same names, same order).
29
+ * No query reads whole rows — admission reads hashes only — so the const is
30
+ * the drift guard's anchor rather than a projection source.
31
+ */
32
+ export declare const FACT_HOLDER_COLUMNS: readonly ["actor_id", "hash", "created_at"];
33
+ /**
34
+ * Record `actor_id` as a holder of `hash`, or refresh the row's `created_at`
35
+ * when it already holds it. The `fact` row must already exist (FK).
36
+ *
37
+ * `created_at` is the holder's most recent put, which the orphan sweep reads
38
+ * as the fact's grace window: a re-put keeps an old orphan out of any
39
+ * sweep whose window covers the put.
40
+ *
41
+ * @param deps - query deps
42
+ * @param actor_id - the actor that brought the bytes
43
+ * @param hash - the fact's hash
44
+ * @mutates fact_holder - inserts the `(actor_id, hash)` row, or sets an existing row's `created_at` to now
45
+ */
46
+ export declare const query_put_fact_holder: (deps: QueryDeps, actor_id: Uuid, hash: FactHash) => Promise<void>;
47
+ /**
48
+ * Whether the `fact_holder` table exists — `false` on a database that mounts
49
+ * the cell verbs without the fact chain.
50
+ *
51
+ * Probed before `query_fact_holder_unheld`, since naming a missing table
52
+ * would abort the caller's transaction. Twin of the Rust admission's
53
+ * `to_regclass('fact_holder')` probe, read from `information_schema` (which
54
+ * every supported driver answers).
55
+ */
56
+ export declare const query_fact_holder_table_exists: (deps: QueryDeps) => Promise<boolean>;
57
+ /**
58
+ * The subset of `hashes` that `actor_id` does **not** hold, in input order.
59
+ *
60
+ * Answered from `fact_holder` alone: a hash with no stored fact and a stored
61
+ * fact the actor never put read the same. Hashes are compared as text. The
62
+ * caller has checked the table exists.
63
+ *
64
+ * @param deps - query deps
65
+ * @param actor_id - the acting actor
66
+ * @param hashes - the candidate hashes
67
+ * @returns the hashes with no `(actor_id, hash)` row
68
+ */
69
+ export declare const query_fact_holder_unheld: (deps: QueryDeps, actor_id: Uuid, hashes: ReadonlyArray<string>) => Promise<Array<string>>;
70
+ //# sourceMappingURL=fact_holder_queries.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fact_holder_queries.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/db/fact_holder_queries.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,kCAAkC,CAAC;AAEjE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AACjD,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,yBAAyB,CAAC;AAEpD,kCAAkC;AAClC,MAAM,WAAW,aAAa;IAC7B,QAAQ,EAAE,IAAI,CAAC;IACf,IAAI,EAAE,QAAQ,CAAC;IACf,UAAU,EAAE,IAAI,CAAC;CACjB;AAED;;;;;;GAMG;AACH,eAAO,MAAM,mBAAmB,6CAIuB,CAAC;AAExD;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,qBAAqB,GACjC,MAAM,SAAS,EACf,UAAU,IAAI,EACd,MAAM,QAAQ,KACZ,OAAO,CAAC,IAAI,CAMd,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,8BAA8B,GAAU,MAAM,SAAS,KAAG,OAAO,CAAC,OAAO,CAQrF,CAAC;AAEF;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,wBAAwB,GACpC,MAAM,SAAS,EACf,UAAU,IAAI,EACd,QAAQ,aAAa,CAAC,MAAM,CAAC,KAC3B,OAAO,CAAC,KAAK,CAAC,MAAM,CAAC,CAQvB,CAAC"}
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Raw queries against `fact_holder` — the possession record.
3
+ *
4
+ * A `fact_holder` row says an actor has put a fact's bytes. `PgFactStore`
5
+ * writes one on every put that names a holder (`HeldFactPutOptions.holder`),
6
+ * deduplicated puts included: identical bytes put again by a second actor
7
+ * make that actor a holder too. Cell ref admission
8
+ * (`auth/cell_ref_admission.ts`) reads it back — a write may introduce a fact
9
+ * ref only for a fact the acting actor holds.
10
+ *
11
+ * Same discipline as `db/fact_queries.ts`: `deps: QueryDeps` first (the caller
12
+ * owns the transaction), no audit side effects, idempotent writes.
13
+ *
14
+ * @module
15
+ */
16
+ /**
17
+ * The `fact_holder` table's columns.
18
+ *
19
+ * Twin of the Rust `fuz_fact::FACT_HOLDER_COLUMNS` (same names, same order).
20
+ * No query reads whole rows — admission reads hashes only — so the const is
21
+ * the drift guard's anchor rather than a projection source.
22
+ */
23
+ export const FACT_HOLDER_COLUMNS = [
24
+ 'actor_id',
25
+ 'hash',
26
+ 'created_at'
27
+ ];
28
+ /**
29
+ * Record `actor_id` as a holder of `hash`, or refresh the row's `created_at`
30
+ * when it already holds it. The `fact` row must already exist (FK).
31
+ *
32
+ * `created_at` is the holder's most recent put, which the orphan sweep reads
33
+ * as the fact's grace window: a re-put keeps an old orphan out of any
34
+ * sweep whose window covers the put.
35
+ *
36
+ * @param deps - query deps
37
+ * @param actor_id - the actor that brought the bytes
38
+ * @param hash - the fact's hash
39
+ * @mutates fact_holder - inserts the `(actor_id, hash)` row, or sets an existing row's `created_at` to now
40
+ */
41
+ export const query_put_fact_holder = async (deps, actor_id, hash) => {
42
+ await deps.db.query(`INSERT INTO fact_holder (actor_id, hash) VALUES ($1, $2)
43
+ ON CONFLICT (actor_id, hash) DO UPDATE SET created_at = NOW()`, [actor_id, hash]);
44
+ };
45
+ /**
46
+ * Whether the `fact_holder` table exists — `false` on a database that mounts
47
+ * the cell verbs without the fact chain.
48
+ *
49
+ * Probed before `query_fact_holder_unheld`, since naming a missing table
50
+ * would abort the caller's transaction. Twin of the Rust admission's
51
+ * `to_regclass('fact_holder')` probe, read from `information_schema` (which
52
+ * every supported driver answers).
53
+ */
54
+ export const query_fact_holder_table_exists = async (deps) => {
55
+ const row = await deps.db.query_one(`SELECT EXISTS (
56
+ SELECT 1 FROM information_schema.tables
57
+ WHERE table_schema = 'public' AND table_name = 'fact_holder'
58
+ ) AS exists`);
59
+ return row?.exists === true;
60
+ };
61
+ /**
62
+ * The subset of `hashes` that `actor_id` does **not** hold, in input order.
63
+ *
64
+ * Answered from `fact_holder` alone: a hash with no stored fact and a stored
65
+ * fact the actor never put read the same. Hashes are compared as text. The
66
+ * caller has checked the table exists.
67
+ *
68
+ * @param deps - query deps
69
+ * @param actor_id - the acting actor
70
+ * @param hashes - the candidate hashes
71
+ * @returns the hashes with no `(actor_id, hash)` row
72
+ */
73
+ export const query_fact_holder_unheld = async (deps, actor_id, hashes) => {
74
+ if (hashes.length === 0)
75
+ return [];
76
+ const rows = await deps.db.query(`SELECT hash FROM fact_holder WHERE actor_id = $1 AND hash = ANY($2::text[])`, [actor_id, hashes]);
77
+ const held = new Set(rows.map((r) => r.hash));
78
+ return hashes.filter((h) => !held.has(h));
79
+ };
@@ -37,6 +37,14 @@ export interface FactMetaRow {
37
37
  size: number | string;
38
38
  created_at: Date;
39
39
  }
40
+ /** Input for `query_put_fact` — one `fact` row. Twin of the Rust `PutFactInput`. */
41
+ export interface PutFactInput {
42
+ hash: FactHash;
43
+ bytes: Uint8Array | null;
44
+ external_url: string | null;
45
+ content_type: string | null;
46
+ size: number;
47
+ }
40
48
  /**
41
49
  * Idempotently insert a fact row.
42
50
  *
@@ -46,13 +54,7 @@ export interface FactMetaRow {
46
54
  * inserted, `false` when a row already existed (caller can use this to
47
55
  * decide whether to also write `fact_ref`).
48
56
  */
49
- export declare const query_put_fact: (deps: QueryDeps, input: {
50
- hash: FactHash;
51
- bytes: Uint8Array | null;
52
- external_url: string | null;
53
- content_type: string | null;
54
- size: number;
55
- }) => Promise<boolean>;
57
+ export declare const query_put_fact: (deps: QueryDeps, input: PutFactInput) => Promise<boolean>;
56
58
  /**
57
59
  * Idempotently insert declared refs for a fact. No-ops on `(source_hash,
58
60
  * target_hash)` collisions and skips the round trip entirely when
@@ -121,14 +123,15 @@ export interface OrphanFactsListResult {
121
123
  * outputs? external fact mirrors?) the predicate moves to a generic
122
124
  * `fact_consumers` registry; today the cell layer is the only consumer.
123
125
  *
124
- * The `older_than` filter applies to `fact.created_at`. Pass `null`
125
- * to skip the filter (used by the list-summary preview); the delete
126
- * handler always passes a non-null cutoff (default 0, meaning "any
127
- * orphan").
126
+ * The `older_than` filter is the grace cutoff: a fact passes it when both
127
+ * its `created_at` and every `fact_holder` row's are older (a re-put
128
+ * refreshes the holder row). Pass `null` to skip the filter (used by the
129
+ * list-summary preview); the delete handler always passes a non-null
130
+ * cutoff (default 0, meaning "any orphan").
128
131
  *
129
132
  * @param deps - query deps
130
- * @param older_than - filter to facts created before this Date (or null
131
- * to skip)
133
+ * @param older_than - filter to facts created, and last put by any holder,
134
+ * before this Date (or null to skip)
132
135
  * @param sample_limit - row cap for the returned `sample`
133
136
  */
134
137
  export declare const query_orphan_facts_list: (deps: QueryDeps, older_than: Date | null, sample_limit: number) => Promise<OrphanFactsListResult>;
@@ -137,7 +140,9 @@ export declare const query_orphan_facts_list: (deps: QueryDeps, older_than: Date
137
140
  * (no row-count limit) — callers iterate to unlink disk files. The
138
141
  * `older_than` cutoff is required (non-null) here: bulk delete should
139
142
  * always be operator-scoped to a time window. A "delete all" sweep
140
- * passes a far-future cutoff, not `null`.
143
+ * passes a far-future cutoff, not `null`. It is the same grace cutoff as
144
+ * `query_orphan_facts_list`: a fact put — or re-put, deduplicated — by an
145
+ * actor within the window is kept, whatever its own `created_at`.
141
146
  */
142
147
  export declare const query_orphan_facts_select_for_delete: (deps: QueryDeps, older_than: Date) => Promise<Array<{
143
148
  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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"}
@@ -113,6 +113,21 @@ export const query_delete_fact = async (deps, hash) => {
113
113
  return null;
114
114
  return { size: Number(row.size), external_url: row.external_url };
115
115
  };
116
+ /**
117
+ * The grace-window half of the orphan cutoff: no `fact_holder` row was
118
+ * recorded (or refreshed) at or after `$1`. A dedup put adds no `fact` row,
119
+ * so `fact.created_at` stays at the first insert — without this, an old
120
+ * orphan an actor re-uploads looks stale, and a sweep between the upload and
121
+ * the cell write that references it deletes the fact (and, by cascade, the
122
+ * possession the write needs). A fact counts as recent when its own
123
+ * `created_at` or any holder's is within the window. Served by
124
+ * `idx_fact_holder_hash`. Twin of the Rust `ORPHAN_FACT_NOT_RECENTLY_HELD`.
125
+ */
126
+ const ORPHAN_FACT_NOT_RECENTLY_HELD = `NOT EXISTS (
127
+ SELECT 1 FROM fact_holder h
128
+ WHERE h.hash = f.hash
129
+ AND h.created_at >= $1::timestamptz
130
+ )`;
116
131
  /**
117
132
  * Compute the "orphan facts" set: rows in `fact` where no active
118
133
  * (non-tombstone) `cell.refs` array contains the hash.
@@ -124,14 +139,15 @@ export const query_delete_fact = async (deps, hash) => {
124
139
  * outputs? external fact mirrors?) the predicate moves to a generic
125
140
  * `fact_consumers` registry; today the cell layer is the only consumer.
126
141
  *
127
- * The `older_than` filter applies to `fact.created_at`. Pass `null`
128
- * to skip the filter (used by the list-summary preview); the delete
129
- * handler always passes a non-null cutoff (default 0, meaning "any
130
- * orphan").
142
+ * The `older_than` filter is the grace cutoff: a fact passes it when both
143
+ * its `created_at` and every `fact_holder` row's are older (a re-put
144
+ * refreshes the holder row). Pass `null` to skip the filter (used by the
145
+ * list-summary preview); the delete handler always passes a non-null
146
+ * cutoff (default 0, meaning "any orphan").
131
147
  *
132
148
  * @param deps - query deps
133
- * @param older_than - filter to facts created before this Date (or null
134
- * to skip)
149
+ * @param older_than - filter to facts created, and last put by any holder,
150
+ * before this Date (or null to skip)
135
151
  * @param sample_limit - row cap for the returned `sample`
136
152
  */
137
153
  export const query_orphan_facts_list = async (deps, older_than, sample_limit) => {
@@ -142,7 +158,8 @@ export const query_orphan_facts_list = async (deps, older_than, sample_limit) =>
142
158
  WHERE c.refs @> ARRAY[f.hash]::text[]
143
159
  AND c.deleted_at IS NULL
144
160
  )
145
- AND ($1::timestamptz IS NULL OR f.created_at < $1::timestamptz)`, [older_than]);
161
+ AND ($1::timestamptz IS NULL
162
+ OR (f.created_at < $1::timestamptz AND ${ORPHAN_FACT_NOT_RECENTLY_HELD}))`, [older_than]);
146
163
  const sample_rows = await deps.db.query(`SELECT hash, size, ${iso8601_timestamp_column('f', 'created_at')} AS created_at, external_url
147
164
  FROM fact f
148
165
  WHERE NOT EXISTS (
@@ -150,7 +167,8 @@ export const query_orphan_facts_list = async (deps, older_than, sample_limit) =>
150
167
  WHERE c.refs @> ARRAY[f.hash]::text[]
151
168
  AND c.deleted_at IS NULL
152
169
  )
153
- AND ($1::timestamptz IS NULL OR f.created_at < $1::timestamptz)
170
+ AND ($1::timestamptz IS NULL
171
+ OR (f.created_at < $1::timestamptz AND ${ORPHAN_FACT_NOT_RECENTLY_HELD}))
154
172
  ORDER BY f.created_at ASC
155
173
  LIMIT $2`, [older_than, sample_limit]);
156
174
  return {
@@ -169,7 +187,9 @@ export const query_orphan_facts_list = async (deps, older_than, sample_limit) =>
169
187
  * (no row-count limit) — callers iterate to unlink disk files. The
170
188
  * `older_than` cutoff is required (non-null) here: bulk delete should
171
189
  * always be operator-scoped to a time window. A "delete all" sweep
172
- * passes a far-future cutoff, not `null`.
190
+ * passes a far-future cutoff, not `null`. It is the same grace cutoff as
191
+ * `query_orphan_facts_list`: a fact put — or re-put, deduplicated — by an
192
+ * actor within the window is kept, whatever its own `created_at`.
173
193
  */
174
194
  export const query_orphan_facts_select_for_delete = async (deps, older_than) => {
175
195
  const rows = await deps.db.query(`SELECT hash, size, external_url
@@ -179,6 +199,7 @@ export const query_orphan_facts_select_for_delete = async (deps, older_than) =>
179
199
  WHERE c.refs @> ARRAY[f.hash]::text[]
180
200
  AND c.deleted_at IS NULL
181
201
  )
182
- AND f.created_at < $1::timestamptz`, [older_than]);
202
+ AND f.created_at < $1::timestamptz
203
+ AND ${ORPHAN_FACT_NOT_RECENTLY_HELD}`, [older_than]);
183
204
  return rows.map((r) => ({ hash: r.hash, size: Number(r.size), external_url: r.external_url }));
184
205
  };