@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.
- 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/cross_backend/spawn_backend.d.ts.map +1 -1
- package/dist/testing/cross_backend/spawn_backend.js +31 -3
- package/dist/testing/cross_backend/testing_server_bun.d.ts.map +1 -1
- package/dist/testing/cross_backend/testing_server_bun.js +29 -2
- 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 +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);
|
package/dist/server/env.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/server/env.d.ts.map
CHANGED
|
@@ -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
|
|
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"}
|
package/dist/server/env.js
CHANGED
|
@@ -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
|
+
};
|