@tangen/db 0.1.59 → 0.1.60

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.
@@ -0,0 +1,26 @@
1
+ -- P5: durable operation correlation, and the hold that protects its evidence.
2
+ --
3
+ -- `correlation_id` is NULLABLE on purpose: every existing writer keeps working
4
+ -- and records nothing, so the column is additive and separately revertible.
5
+ -- The lookup scope is (workspace_id, object_id, correlation_id) - the scope a
6
+ -- recovery scan asks in - which is what the index serves.
7
+ ALTER TABLE "_revisions" ADD COLUMN IF NOT EXISTS "correlation_id" text;
8
+ CREATE INDEX IF NOT EXISTS "revisions_correlation" ON "_revisions" ("workspace_id", "object_id", "correlation_id");
9
+
10
+ -- The hold is GENERIC: it knows a correlation id and an expiry, and nothing
11
+ -- about who took it or why. Retention joins THIS table, never an extension's
12
+ -- own ledger, so core carries no dependency on an extension's schema.
13
+ --
14
+ -- A hold is scoped by workspace AND object, so one workspace's hold cannot
15
+ -- protect another's revision. `expires_at` is what bounds growth: an expired
16
+ -- hold does not protect, and the sweeper reclaims the row.
17
+ CREATE TABLE IF NOT EXISTS "revision_holds" (
18
+ "workspace_id" text NOT NULL,
19
+ "object_id" text NOT NULL,
20
+ "correlation_id" text NOT NULL,
21
+ "holder" text NOT NULL,
22
+ "expires_at" timestamptz NOT NULL,
23
+ "created_at" timestamptz NOT NULL DEFAULT now(),
24
+ PRIMARY KEY ("workspace_id", "object_id", "correlation_id")
25
+ );
26
+ CREATE INDEX IF NOT EXISTS "revision_holds_expiry" ON "revision_holds" ("expires_at");
@@ -428,6 +428,13 @@
428
428
  "when": 1787500380000,
429
429
  "tag": "0061_object_type_path",
430
430
  "breakpoints": true
431
+ },
432
+ {
433
+ "idx": 61,
434
+ "version": "6",
435
+ "when": 1787500440000,
436
+ "tag": "0062_revision_correlation",
437
+ "breakpoints": true
431
438
  }
432
439
  ]
433
440
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangen/db",
3
- "version": "0.1.59",
3
+ "version": "0.1.60",
4
4
  "description": "Database client and system schema (Drizzle + postgres.js) for the Tangen headless CMS",
5
5
  "homepage": "https://github.com/raghunathj/tangen#readme",
6
6
  "bugs": {
@@ -79,7 +79,7 @@
79
79
  "dependencies": {
80
80
  "drizzle-orm": "^0.45.2",
81
81
  "postgres": "^3.4.4",
82
- "@tangen/types": "^0.6.1"
82
+ "@tangen/types": "^0.6.3"
83
83
  },
84
84
  "devDependencies": {
85
85
  "drizzle-kit": "^0.31.11"
@@ -33,7 +33,16 @@ export function contentTableDdl(tableName: string, fieldColsSql: string): string
33
33
  updated_by TEXT,
34
34
  created_by_kind TEXT,
35
35
  updated_by_kind TEXT,
36
- published_by_kind TEXT${fieldColsSql ? ',\n ' + fieldColsSql : ''},
36
+ published_by_kind TEXT,
37
+ -- P1: the row's semantic version. Written by a trigger, never by a
38
+ -- caller, so a conditional write is decidable and no writer - not a
39
+ -- buggy one, not a direct SQL session - can set it backwards.
40
+ version BIGINT NOT NULL DEFAULT 1,
41
+ -- P1: the second of the two numbers. It counts only changes to the
42
+ -- CONTAINER KEY SET of the row, which is what can orphan a locale
43
+ -- overlay. A translator holds this one, so a base title edit does
44
+ -- not refuse their write and a removed block does.
45
+ structure_version BIGINT NOT NULL DEFAULT 1${fieldColsSql ? ',\n ' + fieldColsSql : ''},
37
46
  PRIMARY KEY (id)
38
47
  )
39
48
  `
@@ -48,6 +57,10 @@ export function i18nTableDdl(tableName: string): string {
48
57
  locale TEXT NOT NULL,
49
58
  "__title" TEXT,
50
59
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
60
+ -- P1: this locale's own version, maintained by tangen_bump_locale_version.
61
+ -- One number per locale row, so two translators in two languages never
62
+ -- refuse each other.
63
+ version BIGINT NOT NULL DEFAULT 1,
51
64
  PRIMARY KEY (object_id, locale)
52
65
  )
53
66
  `
@@ -68,3 +81,131 @@ export function pkeyColumnsQuery(tableName: string): string {
68
81
  GROUP BY c.conname
69
82
  `
70
83
  }
84
+
85
+ /**
86
+ * P1: the version trigger.
87
+ *
88
+ * ONE function per database, then one trigger per content table. `BEFORE
89
+ * UPDATE`, so the new value is visible to `RETURNING` in the same statement
90
+ * that performs the conditional write.
91
+ *
92
+ * IT DECIDES FOR ITSELF WHETHER THE WRITE WAS SEMANTIC.
93
+ *
94
+ * The first version took a transaction-local setting - the caller declared
95
+ * "this is metadata only" and the trigger believed it. That is not an
96
+ * authorization boundary: `SET LOCAL tangen.metadata_only = 'on'` is
97
+ * available to any session that can issue SQL, so a semantic write could
98
+ * decline to advance the version simply by saying so, which is precisely
99
+ * the lost update the version exists to prevent.
100
+ *
101
+ * So the trigger compares the row instead. Everything except the bookkeeping
102
+ * columns below is semantic, and ANY change to it advances the version; a
103
+ * write that touches only bookkeeping does not. No caller is consulted and
104
+ * none can opt out, which is what makes it enforcement rather than a
105
+ * convention.
106
+ *
107
+ * The exclusion list is deliberately short. `status` and the `published_*`
108
+ * columns are NOT on it: publishing changes what a visitor sees, so it is
109
+ * semantic. Over-advancing costs a stale editor one reload; under-advancing
110
+ * loses an edit, so the asymmetry decides the doubtful cases.
111
+ *
112
+ * TWO NUMBERS, not one. `version` moves on every semantic change; the
113
+ * structure digest below reduces the row to the set of container `_key`
114
+ * values it holds, at any depth, so `structure_version` moves only when a
115
+ * block or repeater row appears or disappears. That is the write which can
116
+ * orphan a locale overlay, and it is therefore the only base-row change a
117
+ * translator's precondition has to care about. Editing a block's text, or
118
+ * the title, leaves the digest alone and leaves every open translation
119
+ * valid. Reordering leaves it alone too: the keys a locale references all
120
+ * still exist.
121
+ */
122
+
123
+ /** The digest a `structure_version` is decided by: the row's container keys. */
124
+ export const CONTENT_STRUCTURE_DIGEST_SQL = `
125
+ CREATE OR REPLACE FUNCTION tangen_structure_digest(row_json JSONB) RETURNS TEXT AS $$
126
+ SELECT md5(coalesce(string_agg(k, ',' ORDER BY k), ''))
127
+ FROM (SELECT jsonb_array_elements_text(
128
+ coalesce(jsonb_path_query_array(row_json, '$.**."_key"'), '[]'::jsonb)) AS k) s
129
+ $$ LANGUAGE sql IMMUTABLE
130
+ `
131
+
132
+ export const CONTENT_VERSION_FUNCTION_SQL = `
133
+ CREATE OR REPLACE FUNCTION tangen_bump_content_version() RETURNS trigger AS $$
134
+ DECLARE
135
+ bookkeeping text[] := ARRAY['version', 'structure_version', 'updated_at', 'updated_by', 'updated_by_kind'];
136
+ old_sem jsonb := to_jsonb(OLD) - bookkeeping;
137
+ new_sem jsonb := to_jsonb(NEW) - bookkeeping;
138
+ BEGIN
139
+ IF new_sem IS DISTINCT FROM old_sem THEN
140
+ NEW.version := OLD.version + 1;
141
+ NEW.structure_version := OLD.structure_version
142
+ + CASE WHEN tangen_structure_digest(new_sem) IS DISTINCT FROM tangen_structure_digest(old_sem)
143
+ THEN 1 ELSE 0 END;
144
+ ELSE
145
+ NEW.version := OLD.version;
146
+ NEW.structure_version := OLD.structure_version;
147
+ END IF;
148
+ RETURN NEW;
149
+ END $$ LANGUAGE plpgsql
150
+ `
151
+
152
+ /**
153
+ * P1: the locale row's version.
154
+ *
155
+ * A separate function from the content one because an `_i18n` row has no
156
+ * `structure_version` and assigning to a field a row does not have is a
157
+ * runtime error in plpgsql, not a no-op. The rule is otherwise identical:
158
+ * the trigger compares the row and no caller is consulted.
159
+ */
160
+ export const LOCALE_VERSION_FUNCTION_SQL = `
161
+ CREATE OR REPLACE FUNCTION tangen_bump_locale_version() RETURNS trigger AS $$
162
+ DECLARE
163
+ bookkeeping text[] := ARRAY['version', 'updated_at'];
164
+ BEGIN
165
+ IF (to_jsonb(NEW) - bookkeeping) IS DISTINCT FROM (to_jsonb(OLD) - bookkeeping) THEN
166
+ NEW.version := OLD.version + 1;
167
+ ELSE
168
+ NEW.version := OLD.version;
169
+ END IF;
170
+ RETURN NEW;
171
+ END $$ LANGUAGE plpgsql
172
+ `
173
+
174
+ /**
175
+ * The trigger for one content table, as TWO statements.
176
+ *
177
+ * Not one string with a semicolon in it: a simple or parameterised query
178
+ * carries a single statement in most drivers, and PGlite refuses the pair.
179
+ */
180
+ export function contentVersionTriggerSql(tableName: string): string[] {
181
+ return [
182
+ `DROP TRIGGER IF EXISTS tangen_version ON "${tableName}"`,
183
+ `CREATE TRIGGER tangen_version BEFORE UPDATE ON "${tableName}" FOR EACH ROW EXECUTE FUNCTION tangen_bump_content_version()`,
184
+ ]
185
+ }
186
+
187
+ /** Does this table already carry the version trigger? */
188
+ export function contentVersionTriggerQuery(tableName: string): string {
189
+ return `
190
+ SELECT 1 FROM pg_trigger t
191
+ JOIN pg_class c ON c.oid = t.tgrelid
192
+ WHERE c.relname = '${tableName}' AND t.tgname = 'tangen_version' AND NOT t.tgisinternal
193
+ `
194
+ }
195
+
196
+ /** The locale-version trigger for one `_i18n` side table, as TWO statements. */
197
+ export function localeVersionTriggerSql(tableName: string): string[] {
198
+ return [
199
+ `DROP TRIGGER IF EXISTS tangen_locale_version ON "${tableName}"`,
200
+ `CREATE TRIGGER tangen_locale_version BEFORE UPDATE ON "${tableName}" FOR EACH ROW EXECUTE FUNCTION tangen_bump_locale_version()`,
201
+ ]
202
+ }
203
+
204
+ /** Does this side table already carry the locale-version trigger? */
205
+ export function localeVersionTriggerQuery(tableName: string): string {
206
+ return `
207
+ SELECT 1 FROM pg_trigger t
208
+ JOIN pg_class c ON c.oid = t.tgrelid
209
+ WHERE c.relname = '${tableName}' AND t.tgname = 'tangen_locale_version' AND NOT t.tgisinternal
210
+ `
211
+ }
@@ -9,7 +9,7 @@
9
9
  * Dependencies/Notes: drizzle-orm/pg-core; authoritative live content lives in
10
10
  * per-type tables provisioned by `tangen push` — this table only holds snapshots.
11
11
  */
12
- import { pgTable, text, timestamp, jsonb, integer, index, uniqueIndex } from 'drizzle-orm/pg-core'
12
+ import { pgTable, text, timestamp, jsonb, integer, index, uniqueIndex, primaryKey } from 'drizzle-orm/pg-core'
13
13
 
14
14
  // Immutable point-in-time snapshots used for version history.
15
15
  // Authoritative live content lives in per-type tables created by `tangen push`.
@@ -30,9 +30,52 @@ export const revisions = pgTable('_revisions', {
30
30
  createdBy: text('created_by'),
31
31
  /** The locale this revision captured (K5); null for the base row. */
32
32
  locale: text('locale'),
33
+ /**
34
+ * P5: the operation this revision came from, as the originator minted it.
35
+ *
36
+ * Nullable, which is what makes it backward compatible: every existing
37
+ * writer keeps working and records nothing. A recovery scan asks
38
+ * (workspace_id, object_id, correlation_id) - never the row's current
39
+ * version, which cannot tell "my write never landed" from "my write landed
40
+ * and three others followed".
41
+ */
42
+ correlationId: text('correlation_id'),
33
43
  }, (t) => ({
34
44
  idxObject: index('revisions_object').on(t.workspaceId, t.objectId, t.createdAt),
45
+ idxCorrelation: index('revisions_correlation').on(t.workspaceId, t.objectId, t.correlationId),
35
46
  // The invariant the allocator relies on: one version number per object, ever.
36
47
  // Scope matches the allocation scope (workspace + object, not env).
37
48
  uniqObjectVersion: uniqueIndex('_revisions_object_version_uniq').on(t.workspaceId, t.objectId, t.version),
38
49
  }))
50
+
51
+ /**
52
+ * P5: a durable claim that one correlated operation's evidence must survive
53
+ * retention.
54
+ *
55
+ * `createRevision` trims to `maxVersions` oldest-first, so ten subsequent
56
+ * writes to the same object erase the revision a recovery scan would have
57
+ * decided from - minutes on a busy page, not days. A hold is the pin.
58
+ *
59
+ * GENERIC by construction: the table knows a correlation id and an expiry.
60
+ * Retention joins THIS, never an extension's ledger, so core gains no
61
+ * dependency on an extension's schema and any writer that correlates its
62
+ * operations gets the same protection.
63
+ *
64
+ * The hold is taken BEFORE the content mutation. Taken afterwards it leaves a
65
+ * window in which a crash loses the protection and the next trim erases the
66
+ * evidence; taken first it may protect nothing, which costs one row that
67
+ * expires. That is the cheap direction to be wrong in.
68
+ */
69
+ export const revisionHolds = pgTable('revision_holds', {
70
+ workspaceId: text('workspace_id').notNull(),
71
+ objectId: text('object_id').notNull(),
72
+ correlationId: text('correlation_id').notNull(),
73
+ /** Who took it, for diagnosis - never for authority. */
74
+ holder: text('holder').notNull(),
75
+ /** An expired hold does not protect, and the sweeper reclaims the row. */
76
+ expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
77
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
78
+ }, (t) => ({
79
+ pk: primaryKey({ columns: [t.workspaceId, t.objectId, t.correlationId] }),
80
+ idxExpiry: index('revision_holds_expiry').on(t.expiresAt),
81
+ }))