@fuzdev/fuz_app 0.67.0 → 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.
Files changed (168) hide show
  1. package/dist/auth/CLAUDE.md +99 -5
  2. package/dist/auth/account_queries.d.ts +87 -4
  3. package/dist/auth/account_queries.d.ts.map +1 -1
  4. package/dist/auth/account_queries.js +107 -17
  5. package/dist/auth/account_schema.d.ts +19 -0
  6. package/dist/auth/account_schema.d.ts.map +1 -1
  7. package/dist/auth/account_schema.js +8 -0
  8. package/dist/auth/admin_action_specs.d.ts +168 -0
  9. package/dist/auth/admin_action_specs.d.ts.map +1 -1
  10. package/dist/auth/admin_action_specs.js +146 -1
  11. package/dist/auth/admin_actions.d.ts.map +1 -1
  12. package/dist/auth/admin_actions.js +218 -4
  13. package/dist/auth/audit_log_ddl.d.ts +10 -1
  14. package/dist/auth/audit_log_ddl.d.ts.map +1 -1
  15. package/dist/auth/audit_log_ddl.js +13 -4
  16. package/dist/auth/audit_log_schema.d.ts +34 -1
  17. package/dist/auth/audit_log_schema.d.ts.map +1 -1
  18. package/dist/auth/audit_log_schema.js +73 -0
  19. package/dist/auth/auth_ddl.d.ts +2 -2
  20. package/dist/auth/auth_ddl.d.ts.map +1 -1
  21. package/dist/auth/auth_ddl.js +10 -2
  22. package/dist/auth/cell_action_specs.d.ts +1295 -0
  23. package/dist/auth/cell_action_specs.d.ts.map +1 -0
  24. package/dist/auth/cell_action_specs.js +397 -0
  25. package/dist/auth/cell_actions.d.ts +63 -0
  26. package/dist/auth/cell_actions.d.ts.map +1 -0
  27. package/dist/auth/cell_actions.js +546 -0
  28. package/dist/auth/cell_audit_action_specs.d.ts +131 -0
  29. package/dist/auth/cell_audit_action_specs.d.ts.map +1 -0
  30. package/dist/auth/cell_audit_action_specs.js +70 -0
  31. package/dist/auth/cell_audit_actions.d.ts +18 -0
  32. package/dist/auth/cell_audit_actions.d.ts.map +1 -0
  33. package/dist/auth/cell_audit_actions.js +59 -0
  34. package/dist/auth/cell_audit_events.d.ts +28 -0
  35. package/dist/auth/cell_audit_events.d.ts.map +1 -0
  36. package/dist/auth/cell_audit_events.js +42 -0
  37. package/dist/auth/cell_audit_metadata.d.ts +48 -0
  38. package/dist/auth/cell_audit_metadata.d.ts.map +1 -0
  39. package/dist/auth/cell_audit_metadata.js +46 -0
  40. package/dist/auth/cell_authorize.d.ts +88 -0
  41. package/dist/auth/cell_authorize.d.ts.map +1 -0
  42. package/dist/auth/cell_authorize.js +172 -0
  43. package/dist/auth/cell_data_schema.d.ts +44 -0
  44. package/dist/auth/cell_data_schema.d.ts.map +1 -0
  45. package/dist/auth/cell_data_schema.js +42 -0
  46. package/dist/auth/cell_field_action_specs.d.ts +244 -0
  47. package/dist/auth/cell_field_action_specs.d.ts.map +1 -0
  48. package/dist/auth/cell_field_action_specs.js +136 -0
  49. package/dist/auth/cell_field_actions.d.ts +34 -0
  50. package/dist/auth/cell_field_actions.d.ts.map +1 -0
  51. package/dist/auth/cell_field_actions.js +153 -0
  52. package/dist/auth/cell_field_audit_metadata.d.ts +30 -0
  53. package/dist/auth/cell_field_audit_metadata.d.ts.map +1 -0
  54. package/dist/auth/cell_field_audit_metadata.js +28 -0
  55. package/dist/auth/cell_grant_action_specs.d.ts +333 -0
  56. package/dist/auth/cell_grant_action_specs.d.ts.map +1 -0
  57. package/dist/auth/cell_grant_action_specs.js +148 -0
  58. package/dist/auth/cell_grant_actions.d.ts +50 -0
  59. package/dist/auth/cell_grant_actions.d.ts.map +1 -0
  60. package/dist/auth/cell_grant_actions.js +208 -0
  61. package/dist/auth/cell_grant_audit_metadata.d.ts +75 -0
  62. package/dist/auth/cell_grant_audit_metadata.d.ts.map +1 -0
  63. package/dist/auth/cell_grant_audit_metadata.js +54 -0
  64. package/dist/auth/cell_item_action_specs.d.ts +331 -0
  65. package/dist/auth/cell_item_action_specs.d.ts.map +1 -0
  66. package/dist/auth/cell_item_action_specs.js +182 -0
  67. package/dist/auth/cell_item_actions.d.ts +37 -0
  68. package/dist/auth/cell_item_actions.d.ts.map +1 -0
  69. package/dist/auth/cell_item_actions.js +204 -0
  70. package/dist/auth/cell_item_audit_metadata.d.ts +35 -0
  71. package/dist/auth/cell_item_audit_metadata.d.ts.map +1 -0
  72. package/dist/auth/cell_item_audit_metadata.js +32 -0
  73. package/dist/auth/cell_relation_visibility.d.ts +32 -0
  74. package/dist/auth/cell_relation_visibility.d.ts.map +1 -0
  75. package/dist/auth/cell_relation_visibility.js +57 -0
  76. package/dist/auth/deps.d.ts +9 -0
  77. package/dist/auth/deps.d.ts.map +1 -1
  78. package/dist/auth/role_grant_queries.d.ts +30 -0
  79. package/dist/auth/role_grant_queries.d.ts.map +1 -1
  80. package/dist/auth/role_grant_queries.js +54 -0
  81. package/dist/db/CLAUDE.md +118 -0
  82. package/dist/db/cell_audit_queries.d.ts +26 -0
  83. package/dist/db/cell_audit_queries.d.ts.map +1 -0
  84. package/dist/db/cell_audit_queries.js +53 -0
  85. package/dist/db/cell_ddl.d.ts +151 -0
  86. package/dist/db/cell_ddl.d.ts.map +1 -0
  87. package/dist/db/cell_ddl.js +247 -0
  88. package/dist/db/cell_field_queries.d.ts +105 -0
  89. package/dist/db/cell_field_queries.d.ts.map +1 -0
  90. package/dist/db/cell_field_queries.js +113 -0
  91. package/dist/db/cell_grant_queries.d.ts +132 -0
  92. package/dist/db/cell_grant_queries.d.ts.map +1 -0
  93. package/dist/db/cell_grant_queries.js +145 -0
  94. package/dist/db/cell_history_ddl.d.ts +38 -0
  95. package/dist/db/cell_history_ddl.d.ts.map +1 -0
  96. package/dist/db/cell_history_ddl.js +61 -0
  97. package/dist/db/cell_item_queries.d.ts +107 -0
  98. package/dist/db/cell_item_queries.d.ts.map +1 -0
  99. package/dist/db/cell_item_queries.js +119 -0
  100. package/dist/db/cell_queries.d.ts +327 -0
  101. package/dist/db/cell_queries.d.ts.map +1 -0
  102. package/dist/db/cell_queries.js +431 -0
  103. package/dist/db/fact_ddl.d.ts +38 -0
  104. package/dist/db/fact_ddl.d.ts.map +1 -0
  105. package/dist/db/fact_ddl.js +71 -0
  106. package/dist/db/fact_queries.d.ts +140 -0
  107. package/dist/db/fact_queries.d.ts.map +1 -0
  108. package/dist/db/fact_queries.js +161 -0
  109. package/dist/db/fact_store.d.ts +112 -0
  110. package/dist/db/fact_store.d.ts.map +1 -0
  111. package/dist/db/fact_store.js +225 -0
  112. package/dist/server/env.d.ts +2 -0
  113. package/dist/server/env.d.ts.map +1 -1
  114. package/dist/server/env.js +6 -0
  115. package/dist/server/fact_write.d.ts +32 -0
  116. package/dist/server/fact_write.d.ts.map +1 -0
  117. package/dist/server/fact_write.js +56 -0
  118. package/dist/server/file_fact_fetcher.d.ts +42 -0
  119. package/dist/server/file_fact_fetcher.d.ts.map +1 -0
  120. package/dist/server/file_fact_fetcher.js +60 -0
  121. package/dist/server/file_fact_url.d.ts +53 -0
  122. package/dist/server/file_fact_url.d.ts.map +1 -0
  123. package/dist/server/file_fact_url.js +52 -0
  124. package/dist/server/serve_fact_route.d.ts +78 -0
  125. package/dist/server/serve_fact_route.d.ts.map +1 -0
  126. package/dist/server/serve_fact_route.js +205 -0
  127. package/dist/testing/CLAUDE.md +58 -5
  128. package/dist/testing/app_server.d.ts +12 -0
  129. package/dist/testing/app_server.d.ts.map +1 -1
  130. package/dist/testing/app_server.js +36 -2
  131. package/dist/testing/audit_completeness.d.ts.map +1 -1
  132. package/dist/testing/audit_completeness.js +67 -1
  133. package/dist/testing/cross_backend/account_lifecycle.d.ts +10 -0
  134. package/dist/testing/cross_backend/account_lifecycle.d.ts.map +1 -0
  135. package/dist/testing/cross_backend/account_lifecycle.js +76 -0
  136. package/dist/testing/cross_backend/capabilities.d.ts +31 -0
  137. package/dist/testing/cross_backend/capabilities.d.ts.map +1 -1
  138. package/dist/testing/cross_backend/capabilities.js +3 -0
  139. package/dist/testing/cross_backend/cell_cross_helpers.d.ts +39 -0
  140. package/dist/testing/cross_backend/cell_cross_helpers.d.ts.map +1 -0
  141. package/dist/testing/cross_backend/cell_cross_helpers.js +45 -0
  142. package/dist/testing/cross_backend/cell_crud.d.ts +4 -0
  143. package/dist/testing/cross_backend/cell_crud.d.ts.map +1 -0
  144. package/dist/testing/cross_backend/cell_crud.js +168 -0
  145. package/dist/testing/cross_backend/cell_relations.d.ts +4 -0
  146. package/dist/testing/cross_backend/cell_relations.d.ts.map +1 -0
  147. package/dist/testing/cross_backend/cell_relations.js +229 -0
  148. package/dist/testing/cross_backend/default_backend_configs.d.ts.map +1 -1
  149. package/dist/testing/cross_backend/default_backend_configs.js +6 -0
  150. package/dist/testing/cross_backend/setup.d.ts.map +1 -1
  151. package/dist/testing/cross_backend/setup.js +5 -0
  152. package/dist/testing/cross_backend/spawn_backend.d.ts.map +1 -1
  153. package/dist/testing/cross_backend/spawn_backend.js +31 -3
  154. package/dist/testing/cross_backend/testing_server_bun.d.ts.map +1 -1
  155. package/dist/testing/cross_backend/testing_server_bun.js +29 -2
  156. package/dist/testing/entities.d.ts.map +1 -1
  157. package/dist/testing/entities.js +4 -0
  158. package/dist/testing/ws_round_trip.d.ts.map +1 -1
  159. package/dist/testing/ws_round_trip.js +4 -0
  160. package/dist/ui/AdminAccounts.svelte +58 -0
  161. package/dist/ui/AdminAccounts.svelte.d.ts.map +1 -1
  162. package/dist/ui/admin_accounts_state.svelte.d.ts +30 -2
  163. package/dist/ui/admin_accounts_state.svelte.d.ts.map +1 -1
  164. package/dist/ui/admin_accounts_state.svelte.js +45 -1
  165. package/dist/ui/admin_rpc_adapters.d.ts +6 -2
  166. package/dist/ui/admin_rpc_adapters.d.ts.map +1 -1
  167. package/dist/ui/admin_rpc_adapters.js +5 -1
  168. package/package.json +4 -2
@@ -0,0 +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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"}
@@ -0,0 +1,161 @@
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
+ /**
15
+ * Idempotently insert a fact row.
16
+ *
17
+ * `bytes` xor `external_url` per the `facts_storage_present` CHECK
18
+ * constraint; the caller is responsible for satisfying it (the queries
19
+ * layer does not second-guess). Returns `true` when a new row was
20
+ * inserted, `false` when a row already existed (caller can use this to
21
+ * decide whether to also write `fact_refs`).
22
+ */
23
+ export const query_put_fact = async (deps, input) => {
24
+ const row = await deps.db.query_one(`INSERT INTO facts (hash, bytes, external_url, content_type, size)
25
+ VALUES ($1, $2, $3, $4, $5)
26
+ ON CONFLICT (hash) DO NOTHING
27
+ RETURNING hash`, [input.hash, input.bytes, input.external_url, input.content_type, input.size]);
28
+ return row !== undefined;
29
+ };
30
+ /**
31
+ * Idempotently insert declared refs for a fact. No-ops on `(source_hash,
32
+ * target_hash)` collisions and skips the round trip entirely when
33
+ * `target_hashes` is empty.
34
+ */
35
+ export const query_put_fact_refs = async (deps, source_hash, target_hashes) => {
36
+ if (target_hashes.length === 0)
37
+ return;
38
+ await deps.db.query(`INSERT INTO fact_refs (source_hash, target_hash)
39
+ SELECT $1::text, unnest($2::text[])
40
+ ON CONFLICT (source_hash, target_hash) DO NOTHING`, [source_hash, target_hashes]);
41
+ };
42
+ /**
43
+ * Fetch a fact's full row (including embedded `bytes`). Use this from
44
+ * `FactStore.get`; cheaper accessors live below.
45
+ */
46
+ 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
48
+ FROM facts WHERE hash = $1`, [hash]);
49
+ return row ?? null;
50
+ };
51
+ /**
52
+ * Fetch metadata only — skips the (potentially large) `bytes` column.
53
+ */
54
+ 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
56
+ FROM facts WHERE hash = $1`, [hash]);
57
+ return row ?? null;
58
+ };
59
+ /**
60
+ * Cheap existence check. Backed by the `facts` PK index.
61
+ */
62
+ export const query_has_fact = async (deps, hash) => {
63
+ const row = await deps.db.query_one(`SELECT EXISTS(SELECT 1 FROM facts WHERE hash = $1) AS exists`, [hash]);
64
+ return row?.exists ?? false;
65
+ };
66
+ /**
67
+ * List declared targets for a source fact. Order is unspecified; callers
68
+ * that need stable ordering should sort.
69
+ */
70
+ export const query_get_fact_refs = async (deps, source_hash) => {
71
+ const rows = await deps.db.query(`SELECT target_hash FROM fact_refs WHERE source_hash = $1`, [source_hash]);
72
+ return rows.map((r) => r.target_hash);
73
+ };
74
+ /**
75
+ * Drop a fact row. Cascades `fact_refs` rows via the `ON DELETE CASCADE`
76
+ * FK on `source_hash`. Returns the deleted row's `(size, external_url)`
77
+ * so the caller can unlink the disk file (if any) and tally freed bytes,
78
+ * or `null` when no row matched (idempotent: deleting an absent fact is
79
+ * not an error).
80
+ *
81
+ * NOTE: this is a low-level primitive — callers MUST verify the fact is
82
+ * truly orphan (no referencing cell) before calling. The orphan check
83
+ * lives in `query_orphan_facts_*` below; the lifecycle wrapper in
84
+ * `PgFactStore.delete` handles the disk-file unlink.
85
+ */
86
+ export const query_delete_fact = async (deps, hash) => {
87
+ const row = await deps.db.query_one(`DELETE FROM facts WHERE hash = $1
88
+ RETURNING size, external_url`, [hash]);
89
+ if (!row)
90
+ return null;
91
+ return { size: Number(row.size), external_url: row.external_url };
92
+ };
93
+ /**
94
+ * Compute the "orphan facts" set: rows in `facts` where no active
95
+ * (non-tombstone) `cell.refs` array contains the hash.
96
+ *
97
+ * The `cell` join is deliberately app-coupled — `facts` lives in the
98
+ * `fuz_facts` namespace and `cell.refs` lives in `fuz_cell`, but the
99
+ * orphan predicate only makes sense in apps that route content through
100
+ * cells. When a non-cell fact consumer ever appears (signed memo
101
+ * outputs? external fact mirrors?) the predicate moves to a generic
102
+ * `fact_consumers` registry; today the cell layer is the only consumer.
103
+ *
104
+ * The `older_than` filter applies to `facts.created_at`. Pass `null`
105
+ * to skip the filter (used by the list-summary preview); the delete
106
+ * handler always passes a non-null cutoff (default 0, meaning "any
107
+ * orphan").
108
+ *
109
+ * @param deps - query deps
110
+ * @param older_than - filter to facts created before this Date (or null
111
+ * to skip)
112
+ * @param sample_limit - row cap for the returned `sample`
113
+ */
114
+ export const query_orphan_facts_list = async (deps, older_than, sample_limit) => {
115
+ const summary = await deps.db.query_one(`SELECT COUNT(*)::bigint AS count, COALESCE(SUM(size), 0)::bigint AS total
116
+ FROM facts f
117
+ WHERE NOT EXISTS (
118
+ SELECT 1 FROM cell c
119
+ WHERE c.refs @> ARRAY[f.hash]::text[]
120
+ AND c.deleted_at IS NULL
121
+ )
122
+ 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
124
+ FROM facts f
125
+ WHERE NOT EXISTS (
126
+ SELECT 1 FROM cell c
127
+ WHERE c.refs @> ARRAY[f.hash]::text[]
128
+ AND c.deleted_at IS NULL
129
+ )
130
+ AND ($1::timestamptz IS NULL OR f.created_at < $1::timestamptz)
131
+ ORDER BY f.created_at ASC
132
+ LIMIT $2`, [older_than, sample_limit]);
133
+ return {
134
+ count: Number(summary?.count ?? 0),
135
+ total_size_bytes: Number(summary?.total ?? 0),
136
+ sample: sample_rows.map((r) => ({
137
+ hash: r.hash,
138
+ size: Number(r.size),
139
+ created_at: typeof r.created_at === 'string' ? r.created_at : r.created_at.toISOString(),
140
+ external_url: r.external_url,
141
+ })),
142
+ };
143
+ };
144
+ /**
145
+ * Select the orphan-fact hashes for deletion. Returns the rows directly
146
+ * (no row-count limit) — callers iterate to unlink disk files. The
147
+ * `older_than` cutoff is required (non-null) here: bulk delete should
148
+ * always be operator-scoped to a time window. A "delete all" sweep
149
+ * passes a far-future cutoff, not `null`.
150
+ */
151
+ export const query_orphan_facts_select_for_delete = async (deps, older_than) => {
152
+ const rows = await deps.db.query(`SELECT hash, size, external_url
153
+ FROM facts f
154
+ WHERE NOT EXISTS (
155
+ SELECT 1 FROM cell c
156
+ WHERE c.refs @> ARRAY[f.hash]::text[]
157
+ AND c.deleted_at IS NULL
158
+ )
159
+ AND f.created_at < $1::timestamptz`, [older_than]);
160
+ return rows.map((r) => ({ hash: r.hash, size: Number(r.size), external_url: r.external_url }));
161
+ };
@@ -0,0 +1,112 @@
1
+ /**
2
+ * PG-backed `FactStore` implementation.
3
+ *
4
+ * Wraps the raw queries in `db/fact_queries.ts` with the lifecycle the
5
+ * `FactStore` interface promises:
6
+ *
7
+ * - sync hash on `put`, stream hash on `put_ref` (counting bytes against
8
+ * the caller-supplied `size`)
9
+ * - idempotent insert (`ON CONFLICT DO NOTHING` in the queries layer)
10
+ * - JSON ref auto-extraction when `content_type` signals JSON and the
11
+ * caller didn't pass an explicit `refs` array
12
+ * - verify-on-read for external content; embedded reads skip verify
13
+ * because PG storage IS the hash table
14
+ * - mismatched external bytes return `null` + log warning (treat as
15
+ * unavailable; GC / repair is a separate concern)
16
+ *
17
+ * Embedded vs referenced split: callers route by size. `put` rejects
18
+ * `bytes.length > embedded_threshold` so oversized content takes the
19
+ * `put_ref` path explicitly. Auto-split inside `put` is a future option.
20
+ *
21
+ * Wired with a filesystem `file:`-URL fetcher (`create_file_fact_fetcher`)
22
+ * at server assembly: bytes ≤ threshold embed via `put`, larger bytes go
23
+ * through atomic temp+rename onto disk then `put_ref('file:<shard>/<rest>',
24
+ * size)` for verified registration.
25
+ *
26
+ * @module
27
+ */
28
+ import type { QueryDeps } from './query_deps.js';
29
+ import type { Logger } from '@fuzdev/fuz_util/log.js';
30
+ import { type FactHash } from '@fuzdev/fuz_util/fact_hash.js';
31
+ import type { FactMeta, FactPutOptions, FactStore } from '@fuzdev/fuz_util/fact_store.js';
32
+ /** Default embedded-vs-referenced cutoff (1 MiB). */
33
+ export declare const FACT_EMBEDDED_THRESHOLD_DEFAULT: number;
34
+ /** Fetcher abstraction so tests can stub external URL retrieval. */
35
+ export interface FactExternalFetcher {
36
+ fetch_stream: (url: string) => Promise<ReadableStream<Uint8Array>>;
37
+ fetch_bytes: (url: string) => Promise<Uint8Array>;
38
+ }
39
+ /** Default fetcher backed by `globalThis.fetch`. */
40
+ export declare const create_default_fetcher: () => FactExternalFetcher;
41
+ /**
42
+ * Construction-time deps for `PgFactStore`.
43
+ *
44
+ * `embedded_threshold` (bytes) is the inline-vs-external cutoff: payloads
45
+ * at or under it store embedded in the `facts` row, larger ones route to
46
+ * the external fetcher. Defaults to `FACT_EMBEDDED_THRESHOLD_DEFAULT`
47
+ * (1 MiB). Consumers tune it per workload — e.g. a much lower bound
48
+ * (~16 KiB) keeps only small JSON inline and routes image originals +
49
+ * thumbnails external. `fetcher` defaults to a `globalThis.fetch`-backed
50
+ * implementation; tests inject a stub. `log` is optional — the only call
51
+ * site is the verify-mismatch warning path.
52
+ */
53
+ export interface PgFactStoreDeps {
54
+ deps: QueryDeps;
55
+ embedded_threshold?: number;
56
+ fetcher?: FactExternalFetcher;
57
+ log?: Logger;
58
+ }
59
+ /**
60
+ * PG-backed `FactStore`. Delegates to `db/fact_queries.ts` for I/O and adds
61
+ * the lifecycle layer described in the module doc.
62
+ */
63
+ export declare class PgFactStore implements FactStore {
64
+ #private;
65
+ constructor(options: PgFactStoreDeps);
66
+ /**
67
+ * Store small bytes embedded in PG. Rejects oversized content so the
68
+ * caller routes it through `put_ref` explicitly — implicit splitting
69
+ * hides the size decision from the caller.
70
+ */
71
+ put(bytes: Uint8Array, options?: FactPutOptions): Promise<FactHash>;
72
+ /**
73
+ * Stream-hash external content and record `(hash, external_url, size)`.
74
+ * Throws when the streamed byte count disagrees with the caller's
75
+ * declared `size` — a size mismatch usually means the upload was
76
+ * truncated or the URL points at the wrong content.
77
+ */
78
+ put_ref(url: string, size: number, options?: FactPutOptions): Promise<FactHash>;
79
+ /**
80
+ * Retrieve bytes. Embedded reads return PG bytes directly; external
81
+ * reads fetch + verify and return `null` (with a warning log) when
82
+ * the bytes don't match the stored hash.
83
+ */
84
+ get(hash: FactHash): Promise<Uint8Array | null>;
85
+ has(hash: FactHash): Promise<boolean>;
86
+ get_meta(hash: FactHash): Promise<FactMeta | null>;
87
+ get_refs(hash: FactHash): Promise<Array<FactHash>>;
88
+ /**
89
+ * Drop a fact row. `fact_refs` rows referencing this hash as a source
90
+ * cascade via the FK; `fact_refs` targeting this hash do **not** —
91
+ * they remain as dangling pointers, consistent with the federation
92
+ * model where `target_hash` is intentionally not a FK.
93
+ *
94
+ * Idempotent: deleting an absent fact returns `null`. The store does
95
+ * NOT verify the fact is unreferenced — that policy lives one layer
96
+ * up (the orphan-fact admin surface in the consumer; a future GC walker).
97
+ *
98
+ * External-URL unlink is the caller's responsibility — the store
99
+ * doesn't know how to resolve `file:` / `s3:` / etc. URLs to a
100
+ * deletable handle. Caller iterates the returned `external_url`
101
+ * (when non-null) and dispatches to the appropriate cleanup
102
+ * routine. Mirrors the read-side `FactExternalFetcher` split.
103
+ *
104
+ * @returns `{size, external_url}` for the deleted row, or `null` if
105
+ * no row matched the hash.
106
+ */
107
+ delete(hash: FactHash): Promise<{
108
+ size: number;
109
+ external_url: string | null;
110
+ } | null>;
111
+ }
112
+ //# sourceMappingURL=fact_store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fact_store.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/db/fact_store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,iBAAiB,CAAC;AAC/C,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,yBAAyB,CAAC;AAEpD,OAAO,EAKN,KAAK,QAAQ,EACb,MAAM,+BAA+B,CAAC;AACvC,OAAO,KAAK,EAAC,QAAQ,EAAE,cAAc,EAAE,SAAS,EAAC,MAAM,gCAAgC,CAAC;AAYxF,qDAAqD;AACrD,eAAO,MAAM,+BAA+B,QAAc,CAAC;AAE3D,oEAAoE;AACpE,MAAM,WAAW,mBAAmB;IACnC,YAAY,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,cAAc,CAAC,UAAU,CAAC,CAAC,CAAC;IACnE,WAAW,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,UAAU,CAAC,CAAC;CAClD;AAED,oDAAoD;AACpD,eAAO,MAAM,sBAAsB,QAAO,mBAkBxC,CAAC;AAEH;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,eAAe;IAC/B,IAAI,EAAE,SAAS,CAAC;IAChB,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,OAAO,CAAC,EAAE,mBAAmB,CAAC;IAC9B,GAAG,CAAC,EAAE,MAAM,CAAC;CACb;AAED;;;GAGG;AACH,qBAAa,WAAY,YAAW,SAAS;;gBAMhC,OAAO,EAAE,eAAe;IAOpC;;;;OAIG;IACG,GAAG,CAAC,KAAK,EAAE,UAAU,EAAE,OAAO,CAAC,EAAE,cAAc,GAAG,OAAO,CAAC,QAAQ,CAAC;IAoBzE;;;;;OAKG;IACG,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,cAAc,GAAG,OAAO,CAAC,QAAQ,CAAC;IAqBrF;;;;OAIG;IACG,GAAG,CAAC,IAAI,EAAE,QAAQ,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC;IA4B/C,GAAG,CAAC,IAAI,EAAE,QAAQ,GAAG,OAAO,CAAC,OAAO,CAAC;IAIrC,QAAQ,CAAC,IAAI,EAAE,QAAQ,GAAG,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC;IAWlD,QAAQ,CAAC,IAAI,EAAE,QAAQ,GAAG,OAAO,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC;IAIxD;;;;;;;;;;;;;;;;;;OAkBG;IACG,MAAM,CAAC,IAAI,EAAE,QAAQ,GAAG,OAAO,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,YAAY,EAAE,MAAM,GAAG,IAAI,CAAA;KAAC,GAAG,IAAI,CAAC;CAGzF"}
@@ -0,0 +1,225 @@
1
+ /**
2
+ * PG-backed `FactStore` implementation.
3
+ *
4
+ * Wraps the raw queries in `db/fact_queries.ts` with the lifecycle the
5
+ * `FactStore` interface promises:
6
+ *
7
+ * - sync hash on `put`, stream hash on `put_ref` (counting bytes against
8
+ * the caller-supplied `size`)
9
+ * - idempotent insert (`ON CONFLICT DO NOTHING` in the queries layer)
10
+ * - JSON ref auto-extraction when `content_type` signals JSON and the
11
+ * caller didn't pass an explicit `refs` array
12
+ * - verify-on-read for external content; embedded reads skip verify
13
+ * because PG storage IS the hash table
14
+ * - mismatched external bytes return `null` + log warning (treat as
15
+ * unavailable; GC / repair is a separate concern)
16
+ *
17
+ * Embedded vs referenced split: callers route by size. `put` rejects
18
+ * `bytes.length > embedded_threshold` so oversized content takes the
19
+ * `put_ref` path explicitly. Auto-split inside `put` is a future option.
20
+ *
21
+ * Wired with a filesystem `file:`-URL fetcher (`create_file_fact_fetcher`)
22
+ * at server assembly: bytes ≤ threshold embed via `put`, larger bytes go
23
+ * through atomic temp+rename onto disk then `put_ref('file:<shard>/<rest>',
24
+ * size)` for verified registration.
25
+ *
26
+ * @module
27
+ */
28
+ import { fact_hash_bytes, fact_hash_stream, fact_hash_verify, fact_hash_extract_refs, } from '@fuzdev/fuz_util/fact_hash.js';
29
+ import { query_delete_fact, query_get_fact, query_get_fact_meta, query_get_fact_refs, query_has_fact, query_put_fact, query_put_fact_refs, } from './fact_queries.js';
30
+ /** Default embedded-vs-referenced cutoff (1 MiB). */
31
+ export const FACT_EMBEDDED_THRESHOLD_DEFAULT = 1024 * 1024;
32
+ /** Default fetcher backed by `globalThis.fetch`. */
33
+ export const create_default_fetcher = () => ({
34
+ fetch_stream: async (url) => {
35
+ const response = await fetch(url);
36
+ if (!response.ok) {
37
+ throw new Error(`fact fetch failed: ${response.status} ${url}`);
38
+ }
39
+ if (!response.body) {
40
+ throw new Error(`fact fetch returned no body: ${url}`);
41
+ }
42
+ return response.body;
43
+ },
44
+ fetch_bytes: async (url) => {
45
+ const response = await fetch(url);
46
+ if (!response.ok) {
47
+ throw new Error(`fact fetch failed: ${response.status} ${url}`);
48
+ }
49
+ return new Uint8Array(await response.arrayBuffer());
50
+ },
51
+ });
52
+ /**
53
+ * PG-backed `FactStore`. Delegates to `db/fact_queries.ts` for I/O and adds
54
+ * the lifecycle layer described in the module doc.
55
+ */
56
+ export class PgFactStore {
57
+ #deps;
58
+ #embedded_threshold;
59
+ #fetcher;
60
+ #log;
61
+ constructor(options) {
62
+ this.#deps = options.deps;
63
+ this.#embedded_threshold = options.embedded_threshold ?? FACT_EMBEDDED_THRESHOLD_DEFAULT;
64
+ this.#fetcher = options.fetcher ?? create_default_fetcher();
65
+ this.#log = options.log;
66
+ }
67
+ /**
68
+ * Store small bytes embedded in PG. Rejects oversized content so the
69
+ * caller routes it through `put_ref` explicitly — implicit splitting
70
+ * hides the size decision from the caller.
71
+ */
72
+ async put(bytes, options) {
73
+ if (bytes.length > this.#embedded_threshold) {
74
+ throw new Error(`fact bytes exceed embedded threshold (${bytes.length} > ${this.#embedded_threshold}); use put_ref for external storage`);
75
+ }
76
+ const hash = fact_hash_bytes(bytes);
77
+ const inserted = await query_put_fact(this.#deps, {
78
+ hash,
79
+ bytes,
80
+ external_url: null,
81
+ content_type: options?.content_type ?? null,
82
+ size: bytes.length,
83
+ });
84
+ if (inserted) {
85
+ await query_put_fact_refs(this.#deps, hash, resolve_refs(bytes, options));
86
+ }
87
+ return hash;
88
+ }
89
+ /**
90
+ * Stream-hash external content and record `(hash, external_url, size)`.
91
+ * Throws when the streamed byte count disagrees with the caller's
92
+ * declared `size` — a size mismatch usually means the upload was
93
+ * truncated or the URL points at the wrong content.
94
+ */
95
+ async put_ref(url, size, options) {
96
+ const stream = await this.#fetcher.fetch_stream(url);
97
+ const { hash, byte_count } = await hash_counted_stream(stream);
98
+ if (byte_count !== size) {
99
+ throw new Error(`fact size mismatch for ${url}: caller declared ${size}, streamed ${byte_count}`);
100
+ }
101
+ const inserted = await query_put_fact(this.#deps, {
102
+ hash,
103
+ bytes: null,
104
+ external_url: url,
105
+ content_type: options?.content_type ?? null,
106
+ size,
107
+ });
108
+ if (inserted && options?.refs && options.refs.length > 0) {
109
+ await query_put_fact_refs(this.#deps, hash, options.refs);
110
+ }
111
+ return hash;
112
+ }
113
+ /**
114
+ * Retrieve bytes. Embedded reads return PG bytes directly; external
115
+ * reads fetch + verify and return `null` (with a warning log) when
116
+ * the bytes don't match the stored hash.
117
+ */
118
+ async get(hash) {
119
+ const row = await query_get_fact(this.#deps, hash);
120
+ if (!row)
121
+ return null;
122
+ if (row.bytes !== null) {
123
+ return to_uint8(row.bytes);
124
+ }
125
+ if (row.external_url === null) {
126
+ return null;
127
+ }
128
+ let bytes;
129
+ try {
130
+ bytes = await this.#fetcher.fetch_bytes(row.external_url);
131
+ }
132
+ catch (err) {
133
+ this.#log?.warn(`PgFactStore.get fetch failed for ${hash} at ${row.external_url}:`, err instanceof Error ? err.message : String(err));
134
+ return null;
135
+ }
136
+ if (!fact_hash_verify(hash, bytes)) {
137
+ this.#log?.warn(`PgFactStore.get verify mismatch for ${hash} at ${row.external_url}; treating as not-found`);
138
+ return null;
139
+ }
140
+ return bytes;
141
+ }
142
+ async has(hash) {
143
+ return query_has_fact(this.#deps, hash);
144
+ }
145
+ async get_meta(hash) {
146
+ const row = await query_get_fact_meta(this.#deps, hash);
147
+ if (!row)
148
+ return null;
149
+ return {
150
+ content_type: row.content_type,
151
+ size: Number(row.size),
152
+ created_at: row.created_at,
153
+ external: row.external_url !== null,
154
+ };
155
+ }
156
+ async get_refs(hash) {
157
+ return query_get_fact_refs(this.#deps, hash);
158
+ }
159
+ /**
160
+ * Drop a fact row. `fact_refs` rows referencing this hash as a source
161
+ * cascade via the FK; `fact_refs` targeting this hash do **not** —
162
+ * they remain as dangling pointers, consistent with the federation
163
+ * model where `target_hash` is intentionally not a FK.
164
+ *
165
+ * Idempotent: deleting an absent fact returns `null`. The store does
166
+ * NOT verify the fact is unreferenced — that policy lives one layer
167
+ * up (the orphan-fact admin surface in the consumer; a future GC walker).
168
+ *
169
+ * External-URL unlink is the caller's responsibility — the store
170
+ * doesn't know how to resolve `file:` / `s3:` / etc. URLs to a
171
+ * deletable handle. Caller iterates the returned `external_url`
172
+ * (when non-null) and dispatches to the appropriate cleanup
173
+ * routine. Mirrors the read-side `FactExternalFetcher` split.
174
+ *
175
+ * @returns `{size, external_url}` for the deleted row, or `null` if
176
+ * no row matched the hash.
177
+ */
178
+ async delete(hash) {
179
+ return query_delete_fact(this.#deps, hash);
180
+ }
181
+ }
182
+ /**
183
+ * Resolve refs for a `put` call: explicit `refs` win; otherwise auto-extract
184
+ * from JSON content; otherwise no refs.
185
+ */
186
+ const resolve_refs = (bytes, options) => {
187
+ if (options?.refs !== undefined)
188
+ return options.refs;
189
+ if (options?.content_type !== 'application/json')
190
+ return [];
191
+ let value;
192
+ try {
193
+ value = JSON.parse(new TextDecoder().decode(bytes));
194
+ }
195
+ catch {
196
+ // Malformed JSON — caller mislabeled content_type. Fall back to no refs;
197
+ // the alternative (throwing) would surprise callers who set
198
+ // content_type advisorially.
199
+ return [];
200
+ }
201
+ return fact_hash_extract_refs(value);
202
+ };
203
+ /** Hash a stream while counting bytes. Lets `put_ref` verify size in one pass. */
204
+ const hash_counted_stream = async (stream) => {
205
+ let byte_count = 0;
206
+ const counting = new TransformStream({
207
+ transform(chunk, controller) {
208
+ byte_count += chunk.length;
209
+ controller.enqueue(chunk);
210
+ },
211
+ });
212
+ const piped = stream.pipeThrough(counting);
213
+ const hash = await fact_hash_stream(piped);
214
+ return { hash, byte_count };
215
+ };
216
+ /**
217
+ * Coerce whatever the driver returns for BYTEA into a `Uint8Array`.
218
+ *
219
+ * `pg` returns `Buffer` (a `Uint8Array` subclass), `pglite` already returns
220
+ * `Uint8Array`. Wrapping `Buffer` in a fresh `Uint8Array` keeps the
221
+ * downstream type honest without a copy.
222
+ */
223
+ const to_uint8 = (value) => value instanceof Uint8Array && value.constructor === Uint8Array
224
+ ? value
225
+ : new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
@@ -35,6 +35,8 @@ export declare const BaseServerEnv: z.ZodObject<{
35
35
  SMTP_HOST: z.ZodOptional<z.ZodString>;
36
36
  SMTP_USER: z.ZodOptional<z.ZodUnion<readonly [z.ZodEmail, z.ZodLiteral<"">]>>;
37
37
  SMTP_PASSWORD: z.ZodOptional<z.ZodString>;
38
+ FUZ_FACTS_DIR: z.ZodDefault<z.ZodString>;
39
+ FUZ_FACTS_X_ACCEL_REDIRECT_PREFIX: z.ZodOptional<z.ZodString>;
38
40
  }, z.core.$strict>;
39
41
  export type BaseServerEnv = z.infer<typeof BaseServerEnv>;
40
42
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"env.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/server/env.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EAAC,CAAC,EAAC,MAAM,KAAK,CAAC;AAEtB,OAAO,EAA2B,KAAK,OAAO,EAAC,MAAM,oBAAoB,CAAC;AAG1E;;;;;;;GAOG;AACH,eAAO,MAAM,aAAa;;;;;;;;;;;;;;;;;kBAkCxB,CAAC;AACH,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,aAAa,CAAC,CAAC;AAE1D;;GAEG;AACH,MAAM,WAAW,gBAAgB;IAChC,EAAE,EAAE,IAAI,CAAC;IACT,OAAO,EAAE,OAAO,CAAC;IACjB,eAAe,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;IAC/B,oBAAoB,EAAE,MAAM,GAAG,IAAI,CAAC;CACpC;AAED;;GAEG;AACH,MAAM,WAAW,qBAAqB;IACrC,EAAE,EAAE,KAAK,CAAC;IACV,KAAK,EAAE,wBAAwB,GAAG,qBAAqB,CAAC;IACxD,MAAM,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;CACtB;AAED,MAAM,MAAM,sBAAsB,GAAG,gBAAgB,GAAG,qBAAqB,CAAC;AAE9E;;;;;;;;GAQG;AACH,eAAO,MAAM,mBAAmB,GAAI,KAAK,aAAa,KAAG,sBA4BxD,CAAC"}
1
+ {"version":3,"file":"env.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/server/env.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EAAC,CAAC,EAAC,MAAM,KAAK,CAAC;AAEtB,OAAO,EAA2B,KAAK,OAAO,EAAC,MAAM,oBAAoB,CAAC;AAG1E;;;;;;;GAOG;AACH,eAAO,MAAM,aAAa;;;;;;;;;;;;;;;;;;;kBAwCxB,CAAC;AACH,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,aAAa,CAAC,CAAC;AAE1D;;GAEG;AACH,MAAM,WAAW,gBAAgB;IAChC,EAAE,EAAE,IAAI,CAAC;IACT,OAAO,EAAE,OAAO,CAAC;IACjB,eAAe,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;IAC/B,oBAAoB,EAAE,MAAM,GAAG,IAAI,CAAC;CACpC;AAED;;GAEG;AACH,MAAM,WAAW,qBAAqB;IACrC,EAAE,EAAE,KAAK,CAAC;IACV,KAAK,EAAE,wBAAwB,GAAG,qBAAqB,CAAC;IACxD,MAAM,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;CACtB;AAED,MAAM,MAAM,sBAAsB,GAAG,gBAAgB,GAAG,qBAAqB,CAAC;AAE9E;;;;;;;;GAQG;AACH,eAAO,MAAM,mBAAmB,GAAI,KAAK,aAAa,KAAG,sBA4BxD,CAAC"}
@@ -53,6 +53,12 @@ export const BaseServerEnv = z.strictObject({
53
53
  .string()
54
54
  .optional()
55
55
  .meta({ description: 'SMTP authentication password', sensitivity: 'secret' }),
56
+ FUZ_FACTS_DIR: z.string().min(1).default('./.facts').meta({
57
+ description: 'Directory for referenced (large) fact bytes, sharded <shard>/<rest>',
58
+ }),
59
+ FUZ_FACTS_X_ACCEL_REDIRECT_PREFIX: z.string().optional().meta({
60
+ description: 'Internal nginx prefix for X-Accel-Redirect fact delivery (production only)',
61
+ }),
56
62
  });
57
63
  /**
58
64
  * Validate a loaded `BaseServerEnv` and produce the artifacts needed for server init.
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Shared helper for routing fact bytes between embedded (PG `bytes`
3
+ * column) and external (filesystem + `put_ref`) storage tiers based
4
+ * on size.
5
+ *
6
+ * External writes go through atomic temp+rename so the `facts` row
7
+ * never references a partial file; idempotence comes from POSIX
8
+ * `rename` overwrite + `INSERT ... ON CONFLICT DO NOTHING` in the
9
+ * fact-store queries layer.
10
+ *
11
+ * @module
12
+ */
13
+ import { type FactHash } from '@fuzdev/fuz_util/fact_hash.js';
14
+ import type { FactStore } from '@fuzdev/fuz_util/fact_store.js';
15
+ export interface WriteFactOptions {
16
+ content_type: string;
17
+ }
18
+ /**
19
+ * Write `bytes` as a fact, choosing embedded (PG) vs external (disk +
20
+ * `put_ref`) based on `embedded_threshold`. Returns the canonical
21
+ * `blake3:` hash either way.
22
+ *
23
+ * @param fact_store - the `FactStore` (typically `PgFactStore`)
24
+ * @param embedded_threshold - bytes ≤ threshold → embedded; > threshold → disk
25
+ * @param facts_dir - root of the sharded facts directory tree on disk
26
+ * @param bytes - the raw fact bytes
27
+ * @param options - content type for the fact metadata
28
+ * @returns the fact's `blake3:<hex64>` hash
29
+ * @mutates `fact_store`, `facts_dir` filesystem
30
+ */
31
+ export declare const write_fact: (fact_store: FactStore, embedded_threshold: number, facts_dir: string, bytes: Uint8Array, options: WriteFactOptions) => Promise<FactHash>;
32
+ //# sourceMappingURL=fact_write.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fact_write.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/server/fact_write.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAMH,OAAO,EAAoC,KAAK,QAAQ,EAAC,MAAM,+BAA+B,CAAC;AAC/F,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,gCAAgC,CAAC;AAG9D,MAAM,WAAW,gBAAgB;IAChC,YAAY,EAAE,MAAM,CAAC;CACrB;AAED;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,UAAU,GACtB,YAAY,SAAS,EACrB,oBAAoB,MAAM,EAC1B,WAAW,MAAM,EACjB,OAAO,UAAU,EACjB,SAAS,gBAAgB,KACvB,OAAO,CAAC,QAAQ,CA4BlB,CAAC"}
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Shared helper for routing fact bytes between embedded (PG `bytes`
3
+ * column) and external (filesystem + `put_ref`) storage tiers based
4
+ * on size.
5
+ *
6
+ * External writes go through atomic temp+rename so the `facts` row
7
+ * never references a partial file; idempotence comes from POSIX
8
+ * `rename` overwrite + `INSERT ... ON CONFLICT DO NOTHING` in the
9
+ * fact-store queries layer.
10
+ *
11
+ * @module
12
+ */
13
+ import { randomBytes } from 'node:crypto';
14
+ import { writeFile, rename, mkdir, unlink } from 'node:fs/promises';
15
+ import { join } from 'node:path';
16
+ import { FACT_HASH_PREFIX, fact_hash_bytes } from '@fuzdev/fuz_util/fact_hash.js';
17
+ import { mint_file_fact_url } from './file_fact_url.js';
18
+ /**
19
+ * Write `bytes` as a fact, choosing embedded (PG) vs external (disk +
20
+ * `put_ref`) based on `embedded_threshold`. Returns the canonical
21
+ * `blake3:` hash either way.
22
+ *
23
+ * @param fact_store - the `FactStore` (typically `PgFactStore`)
24
+ * @param embedded_threshold - bytes ≤ threshold → embedded; > threshold → disk
25
+ * @param facts_dir - root of the sharded facts directory tree on disk
26
+ * @param bytes - the raw fact bytes
27
+ * @param options - content type for the fact metadata
28
+ * @returns the fact's `blake3:<hex64>` hash
29
+ * @mutates `fact_store`, `facts_dir` filesystem
30
+ */
31
+ export const write_fact = async (fact_store, embedded_threshold, facts_dir, bytes, options) => {
32
+ if (bytes.length <= embedded_threshold) {
33
+ return fact_store.put(bytes, options);
34
+ }
35
+ const hash = fact_hash_bytes(bytes);
36
+ const hex = hash.slice(FACT_HASH_PREFIX.length);
37
+ const shard = hex.slice(0, 2);
38
+ const rest = hex.slice(2);
39
+ const shard_dir = join(facts_dir, shard);
40
+ const tmp_dir = join(facts_dir, '.tmp');
41
+ const tmp_path = join(tmp_dir, `${randomBytes(16).toString('hex')}.tmp`);
42
+ const final_path = join(shard_dir, rest);
43
+ await Promise.all([mkdir(shard_dir, { recursive: true }), mkdir(tmp_dir, { recursive: true })]);
44
+ let renamed = false;
45
+ try {
46
+ await writeFile(tmp_path, bytes);
47
+ await rename(tmp_path, final_path);
48
+ renamed = true;
49
+ }
50
+ finally {
51
+ if (!renamed) {
52
+ await unlink(tmp_path).catch(() => undefined);
53
+ }
54
+ }
55
+ return fact_store.put_ref(mint_file_fact_url(shard, rest), bytes.length, options);
56
+ };