@cloudhal/storage 0.0.0-stage → 0.1.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 (77) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +26 -2
  3. package/dist/migrations/0001_init.d.ts +12 -0
  4. package/dist/migrations/0001_init.js +46 -0
  5. package/dist/migrations/0002_auth.d.ts +7 -0
  6. package/dist/migrations/0002_auth.js +26 -0
  7. package/dist/migrations/0003_agent.d.ts +6 -0
  8. package/dist/migrations/0003_agent.js +32 -0
  9. package/dist/migrations/0004_audit_actor_name.d.ts +3 -0
  10. package/dist/migrations/0004_audit_actor_name.js +7 -0
  11. package/dist/migrations/0005_auth_rate_limit.d.ts +7 -0
  12. package/dist/migrations/0005_auth_rate_limit.js +11 -0
  13. package/dist/migrations/0006_approvals.d.ts +7 -0
  14. package/dist/migrations/0006_approvals.js +31 -0
  15. package/dist/migrations/0007_audit_mode.d.ts +3 -0
  16. package/dist/migrations/0007_audit_mode.js +7 -0
  17. package/dist/migrations/0008_policy_detail.d.ts +7 -0
  18. package/dist/migrations/0008_policy_detail.js +17 -0
  19. package/dist/migrations/0009_audit_elevation.d.ts +3 -0
  20. package/dist/migrations/0009_audit_elevation.js +7 -0
  21. package/dist/migrations/0010_approval_result.d.ts +6 -0
  22. package/dist/migrations/0010_approval_result.js +10 -0
  23. package/dist/migrations/0011_op_guards.d.ts +17 -0
  24. package/dist/migrations/0011_op_guards.js +30 -0
  25. package/dist/migrations/0012_roles.d.ts +23 -0
  26. package/dist/migrations/0012_roles.js +78 -0
  27. package/dist/migrations/0013_media.d.ts +16 -0
  28. package/dist/migrations/0013_media.js +69 -0
  29. package/dist/migrations/0014_analytics.d.ts +19 -0
  30. package/dist/migrations/0014_analytics.js +105 -0
  31. package/dist/migrations/0015_analytics_privacy_signal.d.ts +8 -0
  32. package/dist/migrations/0015_analytics_privacy_signal.js +14 -0
  33. package/dist/migrations/0016_comments.d.ts +27 -0
  34. package/dist/migrations/0016_comments.js +173 -0
  35. package/dist/migrations/0017_schedules.d.ts +21 -0
  36. package/dist/migrations/0017_schedules.js +108 -0
  37. package/dist/migrations/0018_query_indexes.d.ts +12 -0
  38. package/dist/migrations/0018_query_indexes.js +22 -0
  39. package/dist/migrations/0019_body_proposals.d.ts +8 -0
  40. package/dist/migrations/0019_body_proposals.js +24 -0
  41. package/dist/migrations/0020_ai.d.ts +8 -0
  42. package/dist/migrations/0020_ai.js +31 -0
  43. package/dist/migrations/0021_search.d.ts +12 -0
  44. package/dist/migrations/0021_search.js +29 -0
  45. package/dist/migrations/0022_search_kinds.d.ts +12 -0
  46. package/dist/migrations/0022_search_kinds.js +35 -0
  47. package/dist/migrations/0023_search_usage.d.ts +7 -0
  48. package/dist/migrations/0023_search_usage.js +18 -0
  49. package/dist/migrations/0024_search_quota.d.ts +8 -0
  50. package/dist/migrations/0024_search_quota.js +18 -0
  51. package/dist/migrations/0025_translation_groups.d.ts +7 -0
  52. package/dist/migrations/0025_translation_groups.js +15 -0
  53. package/dist/migrations/0026_search_cjk.d.ts +8 -0
  54. package/dist/migrations/0026_search_cjk.js +20 -0
  55. package/dist/migrations/0027_multi_site.d.ts +14 -0
  56. package/dist/migrations/0027_multi_site.js +44 -0
  57. package/dist/migrations/0028_site_members.d.ts +8 -0
  58. package/dist/migrations/0028_site_members.js +22 -0
  59. package/dist/migrations/index.d.ts +3 -0
  60. package/dist/migrations/index.js +61 -0
  61. package/dist/src/blob/fs.d.ts +19 -0
  62. package/dist/src/blob/fs.js +172 -0
  63. package/dist/src/blob/memory.d.ts +18 -0
  64. package/dist/src/blob/memory.js +79 -0
  65. package/dist/src/blob/r2.d.ts +19 -0
  66. package/dist/src/blob/r2.js +146 -0
  67. package/dist/src/index.d.ts +11 -0
  68. package/dist/src/index.js +12 -0
  69. package/dist/src/migrate.d.ts +17 -0
  70. package/dist/src/migrate.js +67 -0
  71. package/dist/src/sql/better-sqlite3.d.ts +23 -0
  72. package/dist/src/sql/better-sqlite3.js +104 -0
  73. package/dist/src/sql/d1.d.ts +13 -0
  74. package/dist/src/sql/d1.js +70 -0
  75. package/dist/src/types.d.ts +131 -0
  76. package/dist/src/types.js +235 -0
  77. package/package.json +76 -3
@@ -0,0 +1,22 @@
1
+
2
+
3
+ /**
4
+ * Indexes for queries that scanned `nodes` or `media_uploads` (D1 bills rows read):
5
+ * - `nodes_sites`: `listSites()` (`WHERE kind = 'site' ORDER BY created_at, id`), which Studio's
6
+ * notification poll runs, scanned every node of every site;
7
+ * - `nodes_site_kind_created`: per-site lists by kind in creation order (pages, media library)
8
+ * sorted in a temporary B-tree. It covers `(site_id, kind)`, so `nodes_site_kind` goes;
9
+ * - `nodes_assets_created`: a key's daily upload limit (`created_at >= ?` over assets);
10
+ * - `media_uploads_expires`: removing expired upload tickets.
11
+ * Index-only: no table or row changes.
12
+ */
13
+ export const migration0018QueryIndexes = {
14
+ name: "0018_query_indexes",
15
+ sql: `
16
+ CREATE INDEX nodes_sites ON nodes (created_at, id) WHERE kind = 'site';
17
+ CREATE INDEX nodes_site_kind_created ON nodes (site_id, kind, created_at, id);
18
+ DROP INDEX nodes_site_kind;
19
+ CREATE INDEX nodes_assets_created ON nodes (created_at) WHERE kind = 'asset';
20
+ CREATE INDEX media_uploads_expires ON media_uploads (expires_at);
21
+ `,
22
+ };
@@ -0,0 +1,8 @@
1
+ import type { Migration } from "../src/migrate.js";
2
+ /**
3
+ * Body proposals (RFC-0014 §5, ADR-0028): `edit_body` in `propose` mode files its operations here
4
+ * instead of writing. A row, not a graph node, until a person accepts it: snapshots, diffs and
5
+ * rollbacks never read this table. `base` holds the hashes of the blocks the operations name, as
6
+ * the agent read them, so an operation whose block changed since is refused as stale.
7
+ */
8
+ export declare const migration0019BodyProposals: Migration;
@@ -0,0 +1,24 @@
1
+
2
+
3
+ /**
4
+ * Body proposals (RFC-0014 §5, ADR-0028): `edit_body` in `propose` mode files its operations here
5
+ * instead of writing. A row, not a graph node, until a person accepts it: snapshots, diffs and
6
+ * rollbacks never read this table. `base` holds the hashes of the blocks the operations name, as
7
+ * the agent read them, so an operation whose block changed since is refused as stale.
8
+ */
9
+ export const migration0019BodyProposals = {
10
+ name: "0019_body_proposals",
11
+ sql: `
12
+ CREATE TABLE body_proposals (
13
+ id TEXT PRIMARY KEY, site_id TEXT NOT NULL,
14
+ node_id TEXT NOT NULL, section_id TEXT,
15
+ base_rev INTEGER NOT NULL, base TEXT NOT NULL, ops TEXT NOT NULL,
16
+ actor TEXT NOT NULL, note TEXT,
17
+ status TEXT NOT NULL,
18
+ created_at TEXT NOT NULL,
19
+ decided_by TEXT, decided_at TEXT, accepted_ops TEXT, result_rev INTEGER
20
+ );
21
+ CREATE INDEX body_proposals_node ON body_proposals (site_id, node_id, created_at);
22
+ CREATE INDEX body_proposals_open ON body_proposals (site_id, created_at) WHERE status = 'open';
23
+ `,
24
+ };
@@ -0,0 +1,8 @@
1
+ import type { Migration } from "../src/migrate.js";
2
+ /**
3
+ * In-editor AI (RFC-0016): per-site settings and per-day usage.
4
+ * - ai_settings: a missing row = AI off. `model` is a Workers AI id or a gateway `provider/model`;
5
+ * `gateway` an AI Gateway id. CAS on `rev`.
6
+ * - ai_usage: one row per site and UTC day, bumped by one upsert per command; the daily cap reads it.
7
+ */
8
+ export declare const migration0020Ai: Migration;
@@ -0,0 +1,31 @@
1
+
2
+
3
+ /**
4
+ * In-editor AI (RFC-0016): per-site settings and per-day usage.
5
+ * - ai_settings: a missing row = AI off. `model` is a Workers AI id or a gateway `provider/model`;
6
+ * `gateway` an AI Gateway id. CAS on `rev`.
7
+ * - ai_usage: one row per site and UTC day, bumped by one upsert per command; the daily cap reads it.
8
+ */
9
+ export const migration0020Ai = {
10
+ name: "0020_ai",
11
+ sql: `
12
+ CREATE TABLE ai_settings (
13
+ site_id TEXT PRIMARY KEY,
14
+ enabled INTEGER NOT NULL,
15
+ model TEXT NOT NULL,
16
+ gateway TEXT,
17
+ daily_limit INTEGER NOT NULL,
18
+ rev INTEGER NOT NULL,
19
+ updated_at TEXT NOT NULL,
20
+ updated_by TEXT
21
+ );
22
+ CREATE TABLE ai_usage (
23
+ site_id TEXT NOT NULL,
24
+ day TEXT NOT NULL,
25
+ requests INTEGER NOT NULL,
26
+ input_tokens INTEGER NOT NULL,
27
+ output_tokens INTEGER NOT NULL,
28
+ PRIMARY KEY (site_id, day)
29
+ );
30
+ `,
31
+ };
@@ -0,0 +1,12 @@
1
+ import type { Migration } from "../src/migrate.js";
2
+ /**
3
+ * Site search (RFC-0019 §3): an FTS5 index of the published articles, rewritten from the snapshot
4
+ * a publish or rollback makes live, so it always matches what readers see. `search_state` records
5
+ * which snapshot the index reflects; a page whose site points elsewhere says "index updating".
6
+ * Time gates are columns, not filters: an embargoed article is indexed and found only once its
7
+ * `from` has passed, an expired one only before its `until`.
8
+ *
9
+ * `wrangler d1 export` skips virtual tables: a restored backup rebuilds the index on the next
10
+ * publish or with `cloudhal search reindex`.
11
+ */
12
+ export declare const migration0021Search: Migration;
@@ -0,0 +1,29 @@
1
+
2
+
3
+ /**
4
+ * Site search (RFC-0019 §3): an FTS5 index of the published articles, rewritten from the snapshot
5
+ * a publish or rollback makes live, so it always matches what readers see. `search_state` records
6
+ * which snapshot the index reflects; a page whose site points elsewhere says "index updating".
7
+ * Time gates are columns, not filters: an embargoed article is indexed and found only once its
8
+ * `from` has passed, an expired one only before its `until`.
9
+ *
10
+ * `wrangler d1 export` skips virtual tables: a restored backup rebuilds the index on the next
11
+ * publish or with `cloudhal search reindex`.
12
+ */
13
+ export const migration0021Search = {
14
+ name: "0021_search",
15
+ sql: `
16
+ CREATE VIRTUAL TABLE search_index USING fts5(
17
+ site_id UNINDEXED, node_id UNINDEXED, locale UNINDEXED,
18
+ title, summary, body,
19
+ href UNINDEXED, published_at UNINDEXED, gate_from UNINDEXED, gate_until UNINDEXED,
20
+ tokenize = 'unicode61 remove_diacritics 2'
21
+ );
22
+ CREATE TABLE search_state (
23
+ site_id TEXT PRIMARY KEY,
24
+ snapshot_id TEXT NOT NULL,
25
+ entries INTEGER NOT NULL,
26
+ updated_at TEXT NOT NULL
27
+ );
28
+ `,
29
+ };
@@ -0,0 +1,12 @@
1
+ import type { Migration } from "../src/migrate.js";
2
+ /**
3
+ * Search kinds (RFC-0029 §2): the index holds pages, terms and authors next to articles. FTS5
4
+ * tables cannot gain columns, so `search_index` is rebuilt with `kind` and `context`, and
5
+ * `search_state` is emptied: the next publish, release, import or `cloudhal search reindex`
6
+ * writes the whole index again.
7
+ *
8
+ * `search_links` lists the terms each indexed article carries, with the article's locale and time
9
+ * gates, so a term or author is found only while its archive lists at least one article the
10
+ * reader can see (the archive's own 404 rule, RFC-0002 §7).
11
+ */
12
+ export declare const migration0022SearchKinds: Migration;
@@ -0,0 +1,35 @@
1
+
2
+
3
+ /**
4
+ * Search kinds (RFC-0029 §2): the index holds pages, terms and authors next to articles. FTS5
5
+ * tables cannot gain columns, so `search_index` is rebuilt with `kind` and `context`, and
6
+ * `search_state` is emptied: the next publish, release, import or `cloudhal search reindex`
7
+ * writes the whole index again.
8
+ *
9
+ * `search_links` lists the terms each indexed article carries, with the article's locale and time
10
+ * gates, so a term or author is found only while its archive lists at least one article the
11
+ * reader can see (the archive's own 404 rule, RFC-0002 §7).
12
+ */
13
+ export const migration0022SearchKinds = {
14
+ name: "0022_search_kinds",
15
+ sql: `
16
+ DROP TABLE IF EXISTS search_index;
17
+ CREATE VIRTUAL TABLE search_index USING fts5(
18
+ site_id UNINDEXED, node_id UNINDEXED, locale UNINDEXED, kind UNINDEXED,
19
+ title, summary, body,
20
+ href UNINDEXED, context UNINDEXED, published_at UNINDEXED, gate_from UNINDEXED, gate_until UNINDEXED,
21
+ tokenize = 'unicode61 remove_diacritics 2'
22
+ );
23
+ DELETE FROM search_state;
24
+ CREATE TABLE search_links (
25
+ site_id TEXT NOT NULL,
26
+ node_id TEXT NOT NULL,
27
+ term_id TEXT NOT NULL,
28
+ locale TEXT NOT NULL,
29
+ gate_from TEXT,
30
+ gate_until TEXT
31
+ );
32
+ CREATE INDEX search_links_term ON search_links (site_id, term_id, locale);
33
+ CREATE INDEX search_links_node ON search_links (site_id, node_id);
34
+ `,
35
+ };
@@ -0,0 +1,7 @@
1
+ import type { Migration } from "../src/migrate.js";
2
+ /**
3
+ * Semantic search budgets (RFC-0029 §3, Security): calls a site's readers made to the Cloudflare AI
4
+ * Search provider per UTC day. Past the deployment's daily limit the site answers from FTS5 alone.
5
+ * One row per site and day; nothing about the reader or the query.
6
+ */
7
+ export declare const migration0023SearchUsage: Migration;
@@ -0,0 +1,18 @@
1
+
2
+
3
+ /**
4
+ * Semantic search budgets (RFC-0029 §3, Security): calls a site's readers made to the Cloudflare AI
5
+ * Search provider per UTC day. Past the deployment's daily limit the site answers from FTS5 alone.
6
+ * One row per site and day; nothing about the reader or the query.
7
+ */
8
+ export const migration0023SearchUsage = {
9
+ name: "0023_search_usage",
10
+ sql: `
11
+ CREATE TABLE search_usage (
12
+ site_id TEXT NOT NULL,
13
+ day TEXT NOT NULL,
14
+ calls INTEGER NOT NULL,
15
+ PRIMARY KEY (site_id, day)
16
+ );
17
+ `,
18
+ };
@@ -0,0 +1,8 @@
1
+ import type { Migration } from "../src/migrate.js";
2
+ /**
3
+ * The semantic search quota (RFC-0029 §3): Cloudflare AI Search includes 1,000 semantic queries a
4
+ * month per account, so the deployment counts its own calls per UTC month (`2026-11`), not per
5
+ * site and day (`0023_search_usage`, which this replaces; it never held a call on a live
6
+ * deployment). Past the limit the site answers from FTS5 until the month ends.
7
+ */
8
+ export declare const migration0024SearchQuota: Migration;
@@ -0,0 +1,18 @@
1
+
2
+
3
+ /**
4
+ * The semantic search quota (RFC-0029 §3): Cloudflare AI Search includes 1,000 semantic queries a
5
+ * month per account, so the deployment counts its own calls per UTC month (`2026-11`), not per
6
+ * site and day (`0023_search_usage`, which this replaces; it never held a call on a live
7
+ * deployment). Past the limit the site answers from FTS5 until the month ends.
8
+ */
9
+ export const migration0024SearchQuota = {
10
+ name: "0024_search_quota",
11
+ sql: `
12
+ DROP TABLE IF EXISTS search_usage;
13
+ CREATE TABLE search_quota (
14
+ period TEXT PRIMARY KEY,
15
+ calls INTEGER NOT NULL
16
+ );
17
+ `,
18
+ };
@@ -0,0 +1,7 @@
1
+ import type { Migration } from "../src/migrate.js";
2
+ /**
3
+ * RFC-0033 invariant 2: a translation group has at most one page (or entry) per locale in a site.
4
+ * An expression index over the node body backs the store's check against concurrent writes; nodes
5
+ * without `translation` are left out by the partial `WHERE`.
6
+ */
7
+ export declare const migration0025TranslationGroups: Migration;
@@ -0,0 +1,15 @@
1
+
2
+
3
+ /**
4
+ * RFC-0033 invariant 2: a translation group has at most one page (or entry) per locale in a site.
5
+ * An expression index over the node body backs the store's check against concurrent writes; nodes
6
+ * without `translation` are left out by the partial `WHERE`.
7
+ */
8
+ export const migration0025TranslationGroups = {
9
+ name: "0025_translation_groups",
10
+ sql: `
11
+ CREATE UNIQUE INDEX nodes_translation_locale ON nodes (
12
+ site_id, kind, json_extract(body, '$.translation.group'), json_extract(body, '$.locale')
13
+ ) WHERE json_extract(body, '$.translation.group') IS NOT NULL;
14
+ `,
15
+ };
@@ -0,0 +1,8 @@
1
+ import type { Migration } from "../src/migrate.js";
2
+ /**
3
+ * RFC-0033 §5: an index for languages written without spaces between words (Chinese, Japanese,
4
+ * Korean, Thai and their neighbours), with FTS5's `trigram` tokenizer; the columns match
5
+ * `search_index`. `search_state` is emptied so the next publish, release, import or
6
+ * `cloudhal search reindex` writes every row again, each into its locale's table.
7
+ */
8
+ export declare const migration0026SearchCjk: Migration;
@@ -0,0 +1,20 @@
1
+
2
+
3
+ /**
4
+ * RFC-0033 §5: an index for languages written without spaces between words (Chinese, Japanese,
5
+ * Korean, Thai and their neighbours), with FTS5's `trigram` tokenizer; the columns match
6
+ * `search_index`. `search_state` is emptied so the next publish, release, import or
7
+ * `cloudhal search reindex` writes every row again, each into its locale's table.
8
+ */
9
+ export const migration0026SearchCjk = {
10
+ name: "0026_search_cjk",
11
+ sql: `
12
+ CREATE VIRTUAL TABLE search_index_cjk USING fts5(
13
+ site_id UNINDEXED, node_id UNINDEXED, locale UNINDEXED, kind UNINDEXED,
14
+ title, summary, body,
15
+ href UNINDEXED, context UNINDEXED, published_at UNINDEXED, gate_from UNINDEXED, gate_until UNINDEXED,
16
+ tokenize = 'trigram'
17
+ );
18
+ DELETE FROM search_state;
19
+ `,
20
+ };
@@ -0,0 +1,14 @@
1
+ import type { Migration } from "../src/migrate.js";
2
+ /**
3
+ * RFC-0034 §4: multi-site installations.
4
+ *
5
+ * - `site_hosts` maps a hostname to the one site it serves. It is derived from `Site.domains` and
6
+ * `Site.localization.domains` and written by the store in the site's own batch. A per-locale
7
+ * domain carries its locale.
8
+ * - `site_workers` lists the site Workers attached to this installation, so a publish purges the
9
+ * Worker that serves the site.
10
+ *
11
+ * The backfill keeps the earliest site when two sites claim the same hostname (`OR IGNORE`, oldest
12
+ * first); `cloudhal site list` and the API report the loser as a domain conflict.
13
+ */
14
+ export declare const migration0027MultiSite: Migration;
@@ -0,0 +1,44 @@
1
+
2
+
3
+ /**
4
+ * RFC-0034 §4: multi-site installations.
5
+ *
6
+ * - `site_hosts` maps a hostname to the one site it serves. It is derived from `Site.domains` and
7
+ * `Site.localization.domains` and written by the store in the site's own batch. A per-locale
8
+ * domain carries its locale.
9
+ * - `site_workers` lists the site Workers attached to this installation, so a publish purges the
10
+ * Worker that serves the site.
11
+ *
12
+ * The backfill keeps the earliest site when two sites claim the same hostname (`OR IGNORE`, oldest
13
+ * first); `cloudhal site list` and the API report the loser as a domain conflict.
14
+ */
15
+ export const migration0027MultiSite = {
16
+ name: "0027_multi_site",
17
+ sql: `
18
+ CREATE TABLE site_hosts (
19
+ host TEXT PRIMARY KEY,
20
+ site_id TEXT NOT NULL,
21
+ locale TEXT,
22
+ primary_host INTEGER NOT NULL DEFAULT 0
23
+ );
24
+ CREATE INDEX site_hosts_site ON site_hosts (site_id);
25
+ CREATE TABLE site_workers (
26
+ name TEXT PRIMARY KEY,
27
+ url TEXT NOT NULL,
28
+ theme TEXT NOT NULL,
29
+ site_ids TEXT NOT NULL,
30
+ updated_at TEXT NOT NULL
31
+ );
32
+ INSERT OR IGNORE INTO site_hosts (host, site_id, locale, primary_host)
33
+ SELECT lower(json_extract(d.value, '$.hostname')), n.site_id, NULL,
34
+ CASE WHEN json_extract(d.value, '$.primary') THEN 1 ELSE 0 END
35
+ FROM nodes n, json_each(n.body, '$.domains') d
36
+ WHERE n.kind = 'site' AND json_extract(d.value, '$.hostname') IS NOT NULL
37
+ ORDER BY n.created_at, n.id;
38
+ INSERT OR IGNORE INTO site_hosts (host, site_id, locale, primary_host)
39
+ SELECT lower(d.value), n.site_id, d.key, 0
40
+ FROM nodes n, json_each(n.body, '$.localization.domains') d
41
+ WHERE n.kind = 'site'
42
+ ORDER BY n.created_at, n.id;
43
+ `,
44
+ };
@@ -0,0 +1,8 @@
1
+ import type { Migration } from "../src/migrate.js";
2
+ /**
3
+ * RFC-0034 phase A2 (RFC-0005 open question 10): per-site roles. A row limits an Editor, Author or
4
+ * Contributor to that site with that role; a member without rows keeps their installation role on
5
+ * every site. `invitation.sites` carries the assignments (JSON `{siteId: role}`) until the invite
6
+ * is accepted, when they become rows in the same batch as the membership.
7
+ */
8
+ export declare const migration0028SiteMembers: Migration;
@@ -0,0 +1,22 @@
1
+
2
+
3
+ /**
4
+ * RFC-0034 phase A2 (RFC-0005 open question 10): per-site roles. A row limits an Editor, Author or
5
+ * Contributor to that site with that role; a member without rows keeps their installation role on
6
+ * every site. `invitation.sites` carries the assignments (JSON `{siteId: role}`) until the invite
7
+ * is accepted, when they become rows in the same batch as the membership.
8
+ */
9
+ export const migration0028SiteMembers = {
10
+ name: "0028_site_members",
11
+ sql: `
12
+ CREATE TABLE site_members (
13
+ site_id TEXT NOT NULL,
14
+ user_id TEXT NOT NULL,
15
+ role TEXT NOT NULL,
16
+ created_at TEXT NOT NULL,
17
+ PRIMARY KEY (site_id, user_id)
18
+ );
19
+ CREATE INDEX site_members_user ON site_members (user_id);
20
+ ALTER TABLE "invitation" ADD COLUMN "sites" text;
21
+ `,
22
+ };
@@ -0,0 +1,3 @@
1
+ import type { Migration } from "../src/migrate.js";
2
+ /** All Cloudhal storage migrations, in order. */
3
+ export declare const migrations: readonly Migration[];
@@ -0,0 +1,61 @@
1
+
2
+ import { migration0001Init } from "./0001_init.js";
3
+ import { migration0002Auth } from "./0002_auth.js";
4
+ import { migration0003Agent } from "./0003_agent.js";
5
+ import { migration0004AuditActorName } from "./0004_audit_actor_name.js";
6
+ import { migration0005AuthRateLimit } from "./0005_auth_rate_limit.js";
7
+ import { migration0006Approvals } from "./0006_approvals.js";
8
+ import { migration0007AuditMode } from "./0007_audit_mode.js";
9
+ import { migration0008PolicyDetail } from "./0008_policy_detail.js";
10
+ import { migration0009AuditElevation } from "./0009_audit_elevation.js";
11
+ import { migration0010ApprovalResult } from "./0010_approval_result.js";
12
+ import { migration0011OpGuards } from "./0011_op_guards.js";
13
+ import { migration0012Roles } from "./0012_roles.js";
14
+ import { migration0013Media } from "./0013_media.js";
15
+ import { migration0014Analytics } from "./0014_analytics.js";
16
+ import { migration0015AnalyticsPrivacySignal } from "./0015_analytics_privacy_signal.js";
17
+ import { migration0016Comments } from "./0016_comments.js";
18
+ import { migration0017Schedules } from "./0017_schedules.js";
19
+ import { migration0018QueryIndexes } from "./0018_query_indexes.js";
20
+ import { migration0019BodyProposals } from "./0019_body_proposals.js";
21
+ import { migration0020Ai } from "./0020_ai.js";
22
+ import { migration0021Search } from "./0021_search.js";
23
+ import { migration0022SearchKinds } from "./0022_search_kinds.js";
24
+ import { migration0023SearchUsage } from "./0023_search_usage.js";
25
+ import { migration0024SearchQuota } from "./0024_search_quota.js";
26
+ import { migration0025TranslationGroups } from "./0025_translation_groups.js";
27
+ import { migration0026SearchCjk } from "./0026_search_cjk.js";
28
+ import { migration0027MultiSite } from "./0027_multi_site.js";
29
+ import { migration0028SiteMembers } from "./0028_site_members.js";
30
+
31
+ /** All Cloudhal storage migrations, in order. */
32
+ export const migrations = [
33
+ migration0001Init,
34
+ migration0002Auth,
35
+ migration0003Agent,
36
+ migration0004AuditActorName,
37
+ migration0005AuthRateLimit,
38
+ migration0006Approvals,
39
+ migration0007AuditMode,
40
+ migration0008PolicyDetail,
41
+ migration0009AuditElevation,
42
+ migration0010ApprovalResult,
43
+ migration0011OpGuards,
44
+ migration0012Roles,
45
+ migration0013Media,
46
+ migration0014Analytics,
47
+ migration0015AnalyticsPrivacySignal,
48
+ migration0016Comments,
49
+ migration0017Schedules,
50
+ migration0018QueryIndexes,
51
+ migration0019BodyProposals,
52
+ migration0020Ai,
53
+ migration0021Search,
54
+ migration0022SearchKinds,
55
+ migration0023SearchUsage,
56
+ migration0024SearchQuota,
57
+ migration0025TranslationGroups,
58
+ migration0026SearchCjk,
59
+ migration0027MultiSite,
60
+ migration0028SiteMembers,
61
+ ];
@@ -0,0 +1,19 @@
1
+ import { type BlobMeta, type BlobPutOptions, type BlobRange, type BlobStore, type BlobStream } from "../types.js";
2
+ /** Filesystem blob store: keys map to files under a root directory (self-host / local dev). */
3
+ export declare class FsBlobStore implements BlobStore {
4
+ #private;
5
+ readonly name = "fs";
6
+ constructor(rootDir: string);
7
+ get(key: string): Promise<Uint8Array | null>;
8
+ head(key: string): Promise<BlobMeta | null>;
9
+ put(key: string, data: Uint8Array, options?: BlobPutOptions): Promise<void>;
10
+ putStream(key: string, data: ReadableStream<Uint8Array>, options: BlobPutOptions & {
11
+ size: number;
12
+ sha256: string;
13
+ }): Promise<void>;
14
+ getStream(key: string, options?: {
15
+ range?: BlobRange;
16
+ }): Promise<BlobStream | null>;
17
+ delete(key: string): Promise<void>;
18
+ list(prefix?: string): Promise<string[]>;
19
+ }
@@ -0,0 +1,172 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { createReadStream, createWriteStream } from "node:fs";
3
+ import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { Readable } from "node:stream";
6
+ import {
7
+ assertBlobKey,
8
+ BlobIntegrityError,
9
+
10
+
11
+
12
+
13
+
14
+ resolveBlobRange,
15
+ SHA256_HEX,
16
+ StorageError,
17
+ verifyBlob,
18
+ } from "../types.js";
19
+
20
+ /** Uploads stream here first and are renamed into place once verified (same filesystem). */
21
+ const TMP_DIR = ".tmp";
22
+
23
+ /** Filesystem blob store: keys map to files under a root directory (self-host / local dev). */
24
+ export class FsBlobStore {
25
+ name = "fs";
26
+ #root ;
27
+
28
+ constructor(rootDir ) {
29
+ this.#root = path.resolve(rootDir);
30
+ }
31
+
32
+ #pathFor(key ) {
33
+ assertBlobKey(key);
34
+ if (key === TMP_DIR || key.startsWith(`${TMP_DIR}/`))
35
+ throw new StorageError(`invalid blob key "${key}"`);
36
+ const file = path.resolve(this.#root, ...key.split("/"));
37
+ // Containment: whatever the key, the file stays under the root (RFC-0004 §4.1).
38
+ if (!file.startsWith(`${this.#root}${path.sep}`))
39
+ throw new StorageError(`invalid blob key "${key}"`);
40
+ return file;
41
+ }
42
+
43
+ async get(key ) {
44
+ try {
45
+ return new Uint8Array(await readFile(this.#pathFor(key)));
46
+ } catch (e) {
47
+ if ((e ).code === "ENOENT") return null;
48
+ throw e;
49
+ }
50
+ }
51
+
52
+ async head(key ) {
53
+ try {
54
+ const s = await stat(this.#pathFor(key));
55
+ return s.isFile() ? { size: s.size } : null;
56
+ } catch (e) {
57
+ if ((e ).code === "ENOENT") return null;
58
+ throw e;
59
+ }
60
+ }
61
+
62
+ async #tmpFile() {
63
+ const dir = path.join(this.#root, TMP_DIR);
64
+ await mkdir(dir, { recursive: true });
65
+ return path.join(dir, randomUUID());
66
+ }
67
+
68
+ /** Writes to a temporary file and renames it into place (atomic on one filesystem). */
69
+ async #commit(file , tmp ) {
70
+ await mkdir(path.dirname(file), { recursive: true });
71
+ await rename(tmp, file);
72
+ }
73
+
74
+ async put(key , data , options = {}) {
75
+ const file = this.#pathFor(key);
76
+ if (options.sha256 !== undefined) await verifyBlob(data, options.sha256);
77
+ const tmp = await this.#tmpFile();
78
+ try {
79
+ await writeFile(tmp, data);
80
+ await this.#commit(file, tmp);
81
+ } catch (e) {
82
+ await rm(tmp, { force: true });
83
+ throw e;
84
+ }
85
+ }
86
+
87
+ async putStream(
88
+ key ,
89
+ data ,
90
+ options ,
91
+ ) {
92
+ const file = this.#pathFor(key);
93
+ if (!SHA256_HEX.test(options.sha256))
94
+ throw new BlobIntegrityError("sha256 must be 64 lower-case hex");
95
+ const tmp = await this.#tmpFile();
96
+ const hash = createHash("sha256");
97
+ const out = createWriteStream(tmp, { flags: "wx" });
98
+ let total = 0;
99
+ try {
100
+ const reader = data.getReader();
101
+ for (;;) {
102
+ const { done, value } = await reader.read();
103
+ if (done) break;
104
+ total += value.byteLength;
105
+ if (total > options.size) {
106
+ await reader.cancel().catch(() => {});
107
+ throw new BlobIntegrityError(`stream is longer than ${options.size} bytes`);
108
+ }
109
+ hash.update(value);
110
+ if (!out.write(value)) await new Promise ((r) => out.once("drain", () => r()));
111
+ }
112
+ await new Promise ((resolve, reject) =>
113
+ out.end((err ) => (err ? reject(err) : resolve())),
114
+ );
115
+ if (total !== options.size)
116
+ throw new BlobIntegrityError(`expected ${options.size} bytes, got ${total}`);
117
+ if (hash.digest("hex") !== options.sha256)
118
+ throw new BlobIntegrityError("data does not match the declared sha256");
119
+ await this.#commit(file, tmp);
120
+ } catch (e) {
121
+ out.destroy();
122
+ await rm(tmp, { force: true });
123
+ throw e;
124
+ }
125
+ }
126
+
127
+ async getStream(key , options = {}) {
128
+ const file = this.#pathFor(key);
129
+ let size ;
130
+ try {
131
+ const s = await stat(file);
132
+ if (!s.isFile()) return null;
133
+ size = s.size;
134
+ } catch (e) {
135
+ if ((e ).code === "ENOENT") return null;
136
+ throw e;
137
+ }
138
+ const range = options.range ? resolveBlobRange(options.range, size) : undefined;
139
+ if (options.range && !range) throw new StorageError("range not satisfiable");
140
+ const stream = createReadStream(
141
+ file,
142
+ range ? { start: range.offset, end: range.offset + range.length - 1 } : {},
143
+ );
144
+ const body = Readable.toWeb(stream) ;
145
+ return range ? { body, size, range } : { body, size };
146
+ }
147
+
148
+ async delete(key ) {
149
+ await rm(this.#pathFor(key), { force: true });
150
+ }
151
+
152
+ async list(prefix = "") {
153
+ const out = [];
154
+ const walk = async (dir , rel ) => {
155
+ let entries ;
156
+ try {
157
+ entries = await readdir(dir, { withFileTypes: true });
158
+ } catch (e) {
159
+ if ((e ).code === "ENOENT") return;
160
+ throw e;
161
+ }
162
+ for (const entry of entries) {
163
+ if (!rel && entry.name === TMP_DIR) continue;
164
+ const key = rel ? `${rel}/${entry.name}` : entry.name;
165
+ if (entry.isDirectory()) await walk(path.join(dir, entry.name), key);
166
+ else if (entry.isFile() && key.startsWith(prefix)) out.push(key);
167
+ }
168
+ };
169
+ await walk(this.#root, "");
170
+ return out.sort();
171
+ }
172
+ }