@yielded/agent-storage-cloudflare 0.1.0-beta.169 → 0.1.0-beta.171

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.
@@ -1,4 +1,8 @@
1
- import { makeSqlSubscriptionStore } from "@yielded/agent-storage-sql/sql-subscription-store";
1
+ import {
2
+ backfillSubscriptionSettlementObservation,
3
+ makeSqlSubscriptionStore,
4
+ } from "@yielded/agent-storage-sql/sql-subscription-store";
5
+ import { sameSqliteLayoutStatement } from "@yielded/agent-storage-sql/sqlite-layout-inspection";
2
6
  import {
3
7
  SourcePartition,
4
8
  SubscriptionError,
@@ -10,7 +14,7 @@ import { Context, Effect, Layer, Schema } from "effect";
10
14
  import * as SqlClientService from "effect/sql/SqlClient";
11
15
  import type { SqlError } from "effect/sql/SqlError";
12
16
 
13
- const CURRENT_SUBSCRIPTION_STORE_VERSION = 3;
17
+ const CURRENT_SUBSCRIPTION_STORE_VERSION = 4;
14
18
 
15
19
  const ScanRow = Schema.Struct({
16
20
  event_scan_cursor: Schema.String,
@@ -64,99 +68,133 @@ const validate = <A, I>(schema: Schema.Codec<A, I>, value: unknown, code: string
64
68
  const decodeRows = <A, I>(schema: Schema.Codec<A, I>, rows: unknown, code: string) =>
65
69
  Schema.decodeUnknownEffect(Schema.Array(schema))(rows).pipe(Effect.mapError(() => corrupt(code)));
66
70
 
67
- const initializeDoSubscriptionStore = Effect.fnUntraced(function* () {
68
- const sql = yield* SqlClientService.SqlClient;
69
-
70
- const names = yield* sql<Record<string, unknown>>`
71
- SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'effect_agent_subscription_%'
72
- `.pipe(Effect.mapError(() => unavailable("inspect subscription storage")));
71
+ const layoutStatements = [
72
+ "CREATE TABLE effect_agent_subscription_store_state ( singleton INTEGER PRIMARY KEY NOT NULL CHECK(singleton=1), storage_version INTEGER NOT NULL, alarm_generation INTEGER NOT NULL )",
73
+ "CREATE TABLE effect_agent_subscription_sequences ( tenant_id TEXT NOT NULL, source_address TEXT NOT NULL, sequence INTEGER NOT NULL, event_scan_cursor TEXT NOT NULL, delivery_scan_cursor TEXT NOT NULL, recovery_scan_cursor INTEGER NOT NULL, PRIMARY KEY (tenant_id, source_address) )",
74
+ "CREATE TABLE effect_agent_subscriptions ( tenant_id TEXT NOT NULL, source_address TEXT NOT NULL, owner_id TEXT NOT NULL, subscription_id TEXT NOT NULL, ordinal INTEGER NOT NULL, source_name TEXT NOT NULL, source_version TEXT NOT NULL, matching_key TEXT NOT NULL, state TEXT NOT NULL, expires_at_millis INTEGER, recovery_at_millis INTEGER, recovery_present INTEGER NOT NULL DEFAULT 0, record_json TEXT NOT NULL, PRIMARY KEY (tenant_id, source_address, owner_id, subscription_id), UNIQUE (tenant_id, source_address, ordinal) )",
75
+ "CREATE INDEX effect_agent_subscriptions_owner ON effect_agent_subscriptions (tenant_id, source_address, owner_id, ordinal)",
76
+ "CREATE INDEX effect_agent_subscriptions_capacity ON effect_agent_subscriptions (tenant_id, source_address, expires_at_millis) WHERE state IN ('active', 'paused')",
77
+ "CREATE INDEX effect_agent_subscriptions_owner_capacity ON effect_agent_subscriptions (tenant_id, source_address, owner_id, expires_at_millis) WHERE state IN ('active', 'paused')",
78
+ "CREATE INDEX effect_agent_subscriptions_candidates ON effect_agent_subscriptions (tenant_id, source_address, source_name, source_version, matching_key, ordinal)",
79
+ "CREATE INDEX effect_agent_subscriptions_recovery ON effect_agent_subscriptions (tenant_id, source_address, recovery_at_millis, ordinal) WHERE recovery_at_millis IS NOT NULL",
80
+ "CREATE TABLE effect_agent_subscription_events ( tenant_id TEXT NOT NULL, source_address TEXT NOT NULL, event_id TEXT NOT NULL, source_name TEXT NOT NULL, source_version TEXT NOT NULL, matching_key TEXT NOT NULL, payload_digest TEXT NOT NULL, cutoff INTEGER NOT NULL, cursor INTEGER NOT NULL, routing_complete INTEGER NOT NULL, tombstone INTEGER NOT NULL DEFAULT 0, next_attempt_at_millis INTEGER NOT NULL, record_json TEXT NOT NULL, PRIMARY KEY (tenant_id, source_address, event_id) )",
81
+ "CREATE INDEX effect_agent_subscription_events_pending ON effect_agent_subscription_events (tenant_id, source_address, routing_complete, next_attempt_at_millis, event_id)",
82
+ "CREATE TABLE effect_agent_subscription_deliveries ( tenant_id TEXT NOT NULL, source_address TEXT NOT NULL, owner_id TEXT NOT NULL, subscription_id TEXT NOT NULL, event_id TEXT NOT NULL, delivery_key TEXT NOT NULL, state TEXT NOT NULL, next_attempt_at_millis INTEGER NOT NULL, record_json TEXT NOT NULL, observe_settlement INTEGER NOT NULL DEFAULT 0 CHECK (observe_settlement IN (0, 1)), PRIMARY KEY (tenant_id, source_address, owner_id, subscription_id, event_id), UNIQUE (tenant_id, source_address, delivery_key) )",
83
+ "CREATE INDEX effect_agent_subscription_deliveries_pending ON effect_agent_subscription_deliveries (tenant_id, source_address, state, next_attempt_at_millis, delivery_key)",
84
+ "CREATE INDEX effect_agent_subscription_deliveries_registration ON effect_agent_subscription_deliveries (tenant_id, source_address, owner_id, subscription_id, delivery_key)",
85
+ "CREATE INDEX effect_agent_subscription_deliveries_capacity ON effect_agent_subscription_deliveries (tenant_id, source_address, owner_id) WHERE state IN ('selected', 'prepared')",
86
+ "CREATE INDEX effect_agent_subscription_deliveries_observing ON effect_agent_subscription_deliveries (tenant_id, source_address, owner_id) WHERE state='delivered' AND observe_settlement=1",
87
+ ] as const;
88
+
89
+ const layoutObjects = layoutStatements.map((statement, index) => {
90
+ const name = /^CREATE (TABLE|INDEX) ([a-z_][a-z_0-9]*)/.exec(statement);
91
+
92
+ if (name === null) throw new Error("Invalid subscription layout statement");
93
+
94
+ return [name[1].toLowerCase(), name[2], index] as const;
95
+ });
73
96
 
74
- const decodedNames = yield* decodeRows(
75
- Schema.Struct({ name: Schema.String }),
76
- names,
77
- "inspect subscription storage",
78
- );
97
+ const addedIndexes = new Set([
98
+ "effect_agent_subscriptions_capacity",
99
+ "effect_agent_subscriptions_owner_capacity",
100
+ "effect_agent_subscription_deliveries_capacity",
101
+ "effect_agent_subscription_deliveries_observing",
102
+ ]);
103
+
104
+ const LayoutObject = Schema.Struct({
105
+ name: Schema.String,
106
+ type: Schema.String,
107
+ sql: Schema.NullOr(Schema.String),
108
+ });
79
109
 
80
- const expected = new Set([
81
- "effect_agent_subscription_store_state",
82
- "effect_agent_subscription_sequences",
83
- "effect_agent_subscriptions",
84
- "effect_agent_subscription_events",
85
- "effect_agent_subscription_deliveries",
86
- ]);
110
+ /** Only the released v3 layout may upgrade; schema, backfill and version publish commit together. */
111
+ const initializeDoSubscriptionStore = Effect.fnUntraced(function* () {
112
+ const sql = (yield* SqlClientService.SqlClient).withoutTransforms();
113
+ const tables = layoutObjects.filter(([type]) => type === "table").map(([, name]) => name);
87
114
 
88
- const hasState = decodedNames.some(
89
- ({ name }) => name === "effect_agent_subscription_store_state",
115
+ const readObjects = sql`SELECT name, type, sql FROM sqlite_master
116
+ WHERE name GLOB 'effect_agent_subscription*' OR (type='trigger' AND tbl_name IN ${sql.in(tables)})`.pipe(
117
+ Effect.flatMap((rows) => decodeRows(LayoutObject, rows, "subscription storage layout")),
90
118
  );
91
119
 
92
- if (!hasState && decodedNames.length > 0) return yield* corrupt("partial subscription storage");
93
- if (!hasState) {
94
- yield* sql
95
- .withTransaction(
96
- Effect.gen(function* () {
97
- yield* sql`CREATE TABLE effect_agent_subscription_store_state (
98
- singleton INTEGER PRIMARY KEY NOT NULL CHECK(singleton=1), storage_version INTEGER NOT NULL, alarm_generation INTEGER NOT NULL
99
- )`.withoutTransform;
100
- yield* sql`CREATE TABLE effect_agent_subscription_sequences (
101
- tenant_id TEXT NOT NULL, source_address TEXT NOT NULL, sequence INTEGER NOT NULL,
102
- event_scan_cursor TEXT NOT NULL, delivery_scan_cursor TEXT NOT NULL, recovery_scan_cursor INTEGER NOT NULL,
103
- PRIMARY KEY (tenant_id, source_address)
104
- )`.withoutTransform;
105
- yield* sql`CREATE TABLE effect_agent_subscriptions (
106
- tenant_id TEXT NOT NULL, source_address TEXT NOT NULL, owner_id TEXT NOT NULL, subscription_id TEXT NOT NULL,
107
- ordinal INTEGER NOT NULL, source_name TEXT NOT NULL, source_version TEXT NOT NULL, matching_key TEXT NOT NULL,
108
- state TEXT NOT NULL, expires_at_millis INTEGER, recovery_at_millis INTEGER, recovery_present INTEGER NOT NULL DEFAULT 0, record_json TEXT NOT NULL,
109
- PRIMARY KEY (tenant_id, source_address, owner_id, subscription_id), UNIQUE (tenant_id, source_address, ordinal)
110
- )`.withoutTransform;
111
- yield* sql`CREATE INDEX effect_agent_subscriptions_owner ON effect_agent_subscriptions (tenant_id, source_address, owner_id, ordinal)`
112
- .withoutTransform;
113
- yield* sql`CREATE INDEX effect_agent_subscriptions_candidates ON effect_agent_subscriptions (tenant_id, source_address, source_name, source_version, matching_key, ordinal)`
114
- .withoutTransform;
115
- yield* sql`CREATE INDEX effect_agent_subscriptions_recovery ON effect_agent_subscriptions (tenant_id, source_address, recovery_at_millis, ordinal) WHERE recovery_at_millis IS NOT NULL`
116
- .withoutTransform;
117
- yield* sql`CREATE TABLE effect_agent_subscription_events (
118
- tenant_id TEXT NOT NULL, source_address TEXT NOT NULL, event_id TEXT NOT NULL, source_name TEXT NOT NULL,
119
- source_version TEXT NOT NULL, matching_key TEXT NOT NULL, payload_digest TEXT NOT NULL, cutoff INTEGER NOT NULL,
120
- cursor INTEGER NOT NULL, routing_complete INTEGER NOT NULL, tombstone INTEGER NOT NULL DEFAULT 0, next_attempt_at_millis INTEGER NOT NULL, record_json TEXT NOT NULL,
121
- PRIMARY KEY (tenant_id, source_address, event_id)
122
- )`.withoutTransform;
123
- yield* sql`CREATE INDEX effect_agent_subscription_events_pending ON effect_agent_subscription_events (tenant_id, source_address, routing_complete, next_attempt_at_millis, event_id)`
124
- .withoutTransform;
125
- yield* sql`CREATE TABLE effect_agent_subscription_deliveries (
126
- tenant_id TEXT NOT NULL, source_address TEXT NOT NULL, owner_id TEXT NOT NULL, subscription_id TEXT NOT NULL,
127
- event_id TEXT NOT NULL, delivery_key TEXT NOT NULL, state TEXT NOT NULL, next_attempt_at_millis INTEGER NOT NULL,
128
- record_json TEXT NOT NULL, PRIMARY KEY (tenant_id, source_address, owner_id, subscription_id, event_id),
129
- UNIQUE (tenant_id, source_address, delivery_key)
130
- )`.withoutTransform;
131
- yield* sql`CREATE INDEX effect_agent_subscription_deliveries_pending ON effect_agent_subscription_deliveries (tenant_id, source_address, state, next_attempt_at_millis, delivery_key)`
132
- .withoutTransform;
133
- yield* sql`CREATE INDEX effect_agent_subscription_deliveries_registration ON effect_agent_subscription_deliveries (tenant_id, source_address, owner_id, subscription_id, delivery_key)`
134
- .withoutTransform;
135
- yield* sql`INSERT INTO effect_agent_subscription_store_state (singleton, storage_version, alarm_generation)
136
- VALUES (1, ${CURRENT_SUBSCRIPTION_STORE_VERSION}, 0)`.withoutTransform;
137
- }),
120
+ const checkLayout = Effect.fnUntraced(function* (
121
+ objects: ReadonlyArray<typeof LayoutObject.Type>,
122
+ version: number,
123
+ ) {
124
+ if (
125
+ objects.some(
126
+ ({ name, type }) =>
127
+ (type === "table" && !tables.includes(name)) ||
128
+ type === "trigger" ||
129
+ (version === 3 && addedIndexes.has(name)),
138
130
  )
139
- .pipe(Effect.mapError(() => unavailable("initialize subscription storage")));
140
-
141
- return;
142
- }
143
- if (decodedNames.length !== expected.size || decodedNames.some(({ name }) => !expected.has(name)))
144
- return yield* corrupt("subscription storage tables");
131
+ )
132
+ return yield* corrupt("ambiguous subscription storage layout");
133
+ for (const [type, name, index] of layoutObjects) {
134
+ if (version === 3 && addedIndexes.has(name)) continue;
135
+
136
+ const expected =
137
+ version === 3
138
+ ? layoutStatements[index].replace(
139
+ ", observe_settlement INTEGER NOT NULL DEFAULT 0 CHECK (observe_settlement IN (0, 1))",
140
+ "",
141
+ )
142
+ : layoutStatements[index];
143
+
144
+ const actual = objects.find((row) => row.name === name);
145
+
146
+ if (
147
+ actual?.type !== type ||
148
+ actual.sql === null ||
149
+ !sameSqliteLayoutStatement(actual.sql, expected)
150
+ )
151
+ return yield* corrupt("incompatible subscription storage layout " + name);
152
+ }
153
+ });
145
154
 
146
- const rows = yield* sql<
147
- Record<string, unknown>
148
- >`SELECT storage_version, alarm_generation FROM effect_agent_subscription_store_state WHERE singleton=1`.pipe(
149
- Effect.mapError(() => unavailable("read subscription storage version")),
150
- );
155
+ yield* sql
156
+ .withTransaction(
157
+ Effect.gen(function* () {
158
+ const objects = yield* readObjects;
151
159
 
152
- const state = yield* decodeRows(StoreStateRow, rows, "read subscription storage version");
160
+ const hasState = objects.some(
161
+ ({ name }) => name === "effect_agent_subscription_store_state",
162
+ );
153
163
 
154
- if (state.length !== 1 || state[0].storage_version !== CURRENT_SUBSCRIPTION_STORE_VERSION)
155
- return yield* corrupt(
156
- state.length === 1
157
- ? `incompatible subscription storage version ${state[0].storage_version}; expected ${CURRENT_SUBSCRIPTION_STORE_VERSION}`
158
- : "invalid subscription storage version row",
159
- );
164
+ if (!hasState) {
165
+ if (objects.length > 0) return yield* corrupt("partial subscription storage");
166
+ for (const statement of layoutStatements) yield* sql.unsafe(statement).withoutTransform;
167
+ yield* sql`INSERT INTO effect_agent_subscription_store_state (singleton, storage_version, alarm_generation)
168
+ VALUES (1, ${CURRENT_SUBSCRIPTION_STORE_VERSION}, 0)`;
169
+
170
+ return;
171
+ }
172
+
173
+ const state = yield* decodeRows(
174
+ StoreStateRow,
175
+ yield* sql`SELECT storage_version, alarm_generation FROM effect_agent_subscription_store_state`,
176
+ "read subscription storage version",
177
+ );
178
+
179
+ if (state.length !== 1) return yield* corrupt("invalid subscription storage version row");
180
+ const version = state[0].storage_version;
181
+
182
+ if (version !== 3 && version !== CURRENT_SUBSCRIPTION_STORE_VERSION)
183
+ return yield* corrupt(
184
+ `incompatible subscription storage version ${version}; expected ${CURRENT_SUBSCRIPTION_STORE_VERSION}`,
185
+ );
186
+ yield* checkLayout(objects, version);
187
+ if (version === CURRENT_SUBSCRIPTION_STORE_VERSION) return;
188
+
189
+ yield* sql`ALTER TABLE effect_agent_subscription_deliveries ADD COLUMN observe_settlement INTEGER NOT NULL DEFAULT 0 CHECK (observe_settlement IN (0, 1))`;
190
+ yield* backfillSubscriptionSettlementObservation();
191
+ for (const [, name, index] of layoutObjects)
192
+ if (addedIndexes.has(name)) yield* sql.unsafe(layoutStatements[index]).withoutTransform;
193
+ yield* sql`UPDATE effect_agent_subscription_store_state SET storage_version=${CURRENT_SUBSCRIPTION_STORE_VERSION} WHERE singleton=1`;
194
+ yield* checkLayout(yield* readObjects, CURRENT_SUBSCRIPTION_STORE_VERSION);
195
+ }),
196
+ )
197
+ .pipe(Effect.catchTag("SqlError", () => unavailable("initialize subscription storage")));
160
198
  });
161
199
 
162
200
  const makeSubscriptionStore = Effect.fnUntraced(function* (owned: SourcePartition) {
@@ -15,7 +15,6 @@ import {
15
15
  canonicalRecordJson,
16
16
  makeSqlThreadArchiveRange,
17
17
  } from "@yielded/agent-storage-sql/sql-thread-archive-range";
18
- import { makeProgressAppendValidation } from "@yielded/agent-storage-sql/sql-thread-native-reads";
19
18
  import { makeSqlThreadWork } from "@yielded/agent-storage-sql/sql-thread-work";
20
19
  import { ThreadId } from "@yielded/agent/identifiers";
21
20
  import {
@@ -395,10 +394,6 @@ const makeJournal = (
395
394
  cause,
396
395
  });
397
396
 
398
- const validateProgress = yield* makeProgressAppendValidation().pipe(
399
- Effect.provideService(SqlClient.SqlClient, sql),
400
- );
401
-
402
397
  const executions = yield* makeSqlExecutions().pipe(
403
398
  Effect.provideService(SqlClient.SqlClient, sql),
404
399
  );
@@ -739,7 +734,6 @@ const makeJournal = (
739
734
  );
740
735
 
741
736
  yield* executionConflict(executions.validate(request));
742
- yield* validateProgress(request);
743
737
 
744
738
  const result = RawAppendResult.make({
745
739
  firstSequence,
@@ -1,4 +1,8 @@
1
- import { SQL_PROMPT_PREDICATE } from "@yielded/agent-storage-sql/sql-thread-native-reads";
1
+ import { backfillPeerMessageRetention } from "@yielded/agent-storage-sql/sql-message-delivery-store";
2
+ import {
3
+ SQL_PROMPT_PREDICATE,
4
+ upgradeExecutionCheckpoints,
5
+ } from "@yielded/agent-storage-sql/sql-thread-native-reads";
2
6
  import { createSqlThreadWorkTables } from "@yielded/agent-storage-sql/sql-thread-work";
3
7
  import { makeSqliteLayoutInspection } from "@yielded/agent-storage-sql/sqlite-layout-inspection";
4
8
  import { CURRENT_RECORD_FORMAT } from "@yielded/agent/records";
@@ -12,7 +16,7 @@ import {
12
16
  DoStorageError,
13
17
  } from "../DoStorageError.ts";
14
18
 
15
- export const CurrentDoStorageVersion = 25;
19
+ export const CurrentDoStorageVersion = 27;
16
20
 
17
21
  /** Fresh layout only. Unsupported stores are rejected before these statements execute. */
18
22
  const layoutStatements = [
@@ -44,7 +48,7 @@ const layoutStatements = [
44
48
  'CREATE INDEX effect_agent_records_subtree ON "effect_agent_canonical_records"(thread_id, source_submission_id, sequence) WHERE record_tag = 22',
45
49
  'CREATE INDEX effect_agent_records_worker_input ON "effect_agent_canonical_records"(thread_id, message_id) WHERE record_tag = 34',
46
50
  "CREATE INDEX effect_agent_submissions_nonterminal ON effect_agent_submissions (thread_id, queue_sequence) WHERE state <> 'settled'",
47
- "CREATE TABLE effect_agent_message_deliveries ( owner_thread_id TEXT NOT NULL, message_id TEXT NOT NULL, version INTEGER NOT NULL, state TEXT NOT NULL, deadline_at_millis INTEGER, record_json TEXT NOT NULL, PRIMARY KEY (owner_thread_id, message_id) )",
51
+ "CREATE TABLE effect_agent_message_deliveries ( owner_thread_id TEXT NOT NULL, message_id TEXT NOT NULL, version INTEGER NOT NULL, state TEXT NOT NULL, deadline_at_millis INTEGER, record_json TEXT NOT NULL, peer_retained_until_millis INTEGER, PRIMARY KEY (owner_thread_id, message_id) )",
48
52
  "CREATE INDEX effect_agent_message_deliveries_due ON effect_agent_message_deliveries (deadline_at_millis, owner_thread_id, message_id) WHERE deadline_at_millis IS NOT NULL",
49
53
  "CREATE INDEX effect_agent_message_deliveries_pending ON \"effect_agent_message_deliveries\"(owner_thread_id, message_id) WHERE state NOT IN ('processed', 'refused')",
50
54
  "CREATE TABLE effect_agent_worker_stops (thread_id TEXT PRIMARY KEY NOT NULL, terminal TEXT)",
@@ -60,7 +64,8 @@ const layoutStatements = [
60
64
  "CREATE INDEX effect_agent_records_worker_completed ON effect_agent_canonical_records(thread_id, message_id, sequence) WHERE record_tag = 32",
61
65
  "CREATE INDEX effect_agent_records_worker_stop ON effect_agent_canonical_records(thread_id, worker_thread_id, sequence) WHERE record_tag = 38",
62
66
  "CREATE INDEX effect_agent_message_deliveries_identity ON effect_agent_message_deliveries(owner_thread_id, json_quote(message_id))",
63
- "CREATE INDEX effect_agent_message_deliveries_peer ON effect_agent_message_deliveries(owner_thread_id, message_id) WHERE state NOT IN ('processed', 'refused') AND json_extract(record_json, '$.envelope.messageAdmission.schemaVersion') = 1",
67
+ "CREATE INDEX effect_agent_message_deliveries_peer_retained ON effect_agent_message_deliveries(owner_thread_id, peer_retained_until_millis, message_id) WHERE peer_retained_until_millis IS NOT NULL",
68
+ "CREATE INDEX effect_agent_message_deliveries_peer ON effect_agent_message_deliveries(owner_thread_id, message_id) WHERE state NOT IN ('processed', 'refused') AND json_extract(record_json, '$.envelope.messageAdmission.peerName') IS NOT NULL",
64
69
  "CREATE TABLE effect_agent_journal_ranges (thread_id TEXT NOT NULL, first_sequence INTEGER NOT NULL, last_sequence INTEGER NOT NULL, record_count INTEGER NOT NULL, batch_count INTEGER NOT NULL, byte_count INTEGER NOT NULL, state TEXT NOT NULL, locator TEXT, PRIMARY KEY (thread_id, first_sequence), FOREIGN KEY (thread_id) REFERENCES effect_agent_threads(thread_id) ON DELETE RESTRICT)",
65
70
  "CREATE TABLE effect_agent_archive_batches (thread_id TEXT NOT NULL, batch_id TEXT NOT NULL, range_first_sequence INTEGER NOT NULL, batch_json TEXT NOT NULL, PRIMARY KEY (thread_id, batch_id), FOREIGN KEY (thread_id, batch_id) REFERENCES effect_agent_canonical_batches(thread_id, batch_id) ON DELETE RESTRICT, CONSTRAINT effect_agent_archive_batches_range_fkey FOREIGN KEY (thread_id, range_first_sequence) REFERENCES effect_agent_journal_ranges(thread_id, first_sequence) ON DELETE RESTRICT)",
66
71
  "CREATE TABLE effect_agent_archive_records (thread_id TEXT NOT NULL, sequence INTEGER NOT NULL, range_first_sequence INTEGER NOT NULL, record_json TEXT NOT NULL, PRIMARY KEY (thread_id, sequence), FOREIGN KEY (thread_id, sequence) REFERENCES effect_agent_canonical_records(thread_id, sequence) ON DELETE RESTRICT, CONSTRAINT effect_agent_archive_records_range_fkey FOREIGN KEY (thread_id, range_first_sequence) REFERENCES effect_agent_journal_ranges(thread_id, first_sequence) ON DELETE RESTRICT)",
@@ -82,13 +87,14 @@ const layoutStatements = [
82
87
  const headerStatement =
83
88
  "CREATE TABLE effect_agent_schema (singleton INTEGER PRIMARY KEY NOT NULL CHECK (singleton = 1), layout_version INTEGER NOT NULL CHECK (layout_version > 0), record_format TEXT NOT NULL CHECK (length(record_format) > 0))";
84
89
 
85
- const layoutObjects = layoutStatements.map((statement, index) => {
86
- const name = /^CREATE (TABLE|(?:UNIQUE )?INDEX|TRIGGER) "?([a-z_][a-z_0-9]*)"?/.exec(statement);
90
+ const layoutObjects = (statements: ReadonlyArray<string>) =>
91
+ statements.map((statement, index) => {
92
+ const name = /^CREATE (TABLE|(?:UNIQUE )?INDEX|TRIGGER) "?([a-z_][a-z_0-9]*)"?/.exec(statement);
87
93
 
88
- if (name === null) throw new Error("Invalid fresh layout statement");
94
+ if (name === null) throw new Error("Invalid fresh layout statement");
89
95
 
90
- return [name[1].replace("UNIQUE ", "").toLowerCase(), name[2], index] as const;
91
- });
96
+ return [name[1].replace("UNIQUE ", "").toLowerCase(), name[2], index] as const;
97
+ });
92
98
 
93
99
  const Legacy = Schema.Tuple([
94
100
  Schema.Struct({
@@ -115,22 +121,43 @@ const incompatible = (actualVersion: number, message: string) =>
115
121
  const storageError = (cause: SqlError) =>
116
122
  DoStorageError.make({ operation: "inspect storage layout", cause, message: cause.message });
117
123
 
118
- const { decode, readObjects, readHeader } = makeSqliteLayoutInspection({
119
- version: CurrentDoStorageVersion,
120
- statements: layoutStatements,
121
- objects: layoutObjects,
122
- headerStatement,
123
- incompatible,
124
- storageError,
125
- corruption: (table) =>
126
- DoStorageCorruptionError.make({
127
- table,
128
- rowKey: "schema",
129
- message: "Malformed storage layout or version header.",
130
- }),
131
- });
124
+ const inspection = (version: number, statements: ReadonlyArray<string>) =>
125
+ makeSqliteLayoutInspection({
126
+ version,
127
+ statements,
128
+ objects: layoutObjects(statements),
129
+ headerStatement,
130
+ incompatible,
131
+ storageError,
132
+ corruption: (table) =>
133
+ DoStorageCorruptionError.make({
134
+ table,
135
+ rowKey: "schema",
136
+ message: "Malformed storage layout or version header.",
137
+ }),
138
+ });
132
139
 
133
- const inspectStorage = Effect.fnUntraced(function* (): Effect.fn.Return<
140
+ // Released predecessors retain the same record format and canonical tables.
141
+ const addedIndexes = new Set(["effect_agent_message_deliveries_peer_retained"]);
142
+
143
+ const layout25Statements = layoutObjects(layoutStatements)
144
+ .filter(([, name]) => !addedIndexes.has(name))
145
+ .map(([, , index]) =>
146
+ layoutStatements[index]
147
+ .replace(", peer_retained_until_millis INTEGER", "")
148
+ .replace(
149
+ "'$.envelope.messageAdmission.peerName') IS NOT NULL",
150
+ "'$.envelope.messageAdmission.schemaVersion') = 1",
151
+ ),
152
+ );
153
+
154
+ const { decode, readObjects, readHeader } = inspection(CurrentDoStorageVersion, layoutStatements);
155
+ const layout25 = inspection(25, layout25Statements);
156
+ const layout26 = inspection(26, layoutStatements);
157
+
158
+ const inspectStorage = Effect.fnUntraced(function* (
159
+ allowUpgrade = false,
160
+ ): Effect.fn.Return<
134
161
  DoStorageHeader | undefined,
135
162
  DoStorageCompatibilityError | DoStorageCorruptionError | DoStorageError,
136
163
  SqlClient.SqlClient
@@ -157,6 +184,17 @@ const inspectStorage = Effect.fnUntraced(function* (): Effect.fn.Return<
157
184
 
158
185
  const version = legacy.value;
159
186
 
187
+ if (allowUpgrade && (version === 25 || version === 26)) {
188
+ if (version === 25 && objects.some(({ name }) => addedIndexes.has(name)))
189
+ return yield* incompatible(version, "Mixed layout 25 and 26 objects.");
190
+ const predecessor = version === 25 ? layout25 : layout26;
191
+ const header = yield* predecessor.readHeader(objects, version);
192
+
193
+ yield* predecessor.readManagedTriggers(true);
194
+
195
+ return header;
196
+ }
197
+
160
198
  return yield* readHeader(objects, version);
161
199
  });
162
200
 
@@ -170,15 +208,46 @@ export const readDoStorageHeader = Effect.fnUntraced(function* () {
170
208
  return header;
171
209
  });
172
210
 
173
- /** Validate under the writer transaction before any DDL; initialize only a fresh store. */
211
+ /** Initialize fresh storage or atomically upgrade the exact released layouts 25 and 26. */
174
212
  export const ensureDoStorageLayout = Effect.fn("DoStorage.initializeLayout")(function* () {
175
213
  const sql = (yield* SqlClient.SqlClient).withoutTransforms();
176
214
 
177
215
  return yield* sql
178
216
  .withTransaction(
179
217
  Effect.gen(function* () {
180
- const header = yield* inspectStorage();
218
+ const header = yield* inspectStorage(true);
219
+
220
+ if (header?.layoutVersion === 25) {
221
+ yield* sql`ALTER TABLE effect_agent_message_deliveries ADD COLUMN peer_retained_until_millis INTEGER`;
222
+ yield* backfillPeerMessageRetention().pipe(
223
+ Effect.catchTag("MessageDeliveryError", () =>
224
+ DoStorageCorruptionError.make({
225
+ table: "effect_agent_message_deliveries",
226
+ rowKey: "upgrade",
227
+ message: "Invalid layout-25 delivery evidence; upgrade rolled back.",
228
+ }),
229
+ ),
230
+ );
231
+ yield* sql`DROP INDEX effect_agent_message_deliveries_peer`;
232
+ for (const [, name, index] of layoutObjects(layoutStatements))
233
+ if (addedIndexes.has(name) || name === "effect_agent_message_deliveries_peer")
234
+ yield* sql.unsafe(layoutStatements[index]).withoutTransform;
235
+ }
236
+ if (header?.layoutVersion === 25 || header?.layoutVersion === 26) {
237
+ yield* upgradeExecutionCheckpoints(undefined).pipe(
238
+ Effect.catchTag("ThreadStoreError", (cause) =>
239
+ DoStorageCorruptionError.make({
240
+ table: "effect_agent_executions",
241
+ rowKey: "upgrade",
242
+ message: `Invalid retained execution; upgrade rolled back: ${cause.message}`,
243
+ }),
244
+ ),
245
+ );
246
+ yield* sql`UPDATE effect_agent_schema SET layout_version=${CurrentDoStorageVersion} WHERE singleton=1`;
247
+ yield* sql`UPDATE effect_agent_meta SET value=${String(CurrentDoStorageVersion)} WHERE key='storage_version'`;
181
248
 
249
+ return yield* readDoStorageHeader();
250
+ }
182
251
  if (header !== undefined) return header;
183
252
  for (const statement of layoutStatements) yield* sql.unsafe(statement).withoutTransform;
184
253
  yield* createSqlThreadWorkTables().pipe(