@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.
|
|
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.
|
|
82
|
+
"@tangen/types": "^0.6.3"
|
|
83
83
|
},
|
|
84
84
|
"devDependencies": {
|
|
85
85
|
"drizzle-kit": "^0.31.11"
|
package/src/content-ddl.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/src/schema/objects.ts
CHANGED
|
@@ -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
|
+
}))
|