@streamsy/storage 0.5.0 → 0.7.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/README.md CHANGED
@@ -122,6 +122,10 @@ In a Durable Object, use `DurableObjectStorage.layerProtocol` with
122
122
  rejected without being changed. Use a fresh database path.
123
123
  - Stream ids are limited to 512 characters, and producer ids to 255. A longer
124
124
  id fails with a non-retryable `StorageFault` before anything is written.
125
+ - Projection checkpoint keys have no length limit. MySQL stores 512 key
126
+ characters, so on MySQL a longer key is stored as its first 447 characters,
127
+ `#` and the SHA-256 hex of the full key. The other dialects store every key
128
+ as it is.
125
129
  - Bun defaults the client `busyTimeout` to zero and retries storage-owned
126
130
  transactions with a bounded yielding policy. Tune `client.busyTimeout`,
127
131
  `transactionRetryAttempts`, and `transactionRetryDelayMs` if you need to.
package/dist/bun.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { r as SqlStorageOptions } from "./storage-kHdg037u.js";
1
+ import { r as SqlStorageOptions } from "./storage-C1XYywlK.js";
2
2
  import { Layer } from "effect";
3
3
  import { StorageFault, Streamsy } from "@streamsy/core";
4
4
  import { Protocol } from "@streamsy/core/protocol";
@@ -12,8 +12,8 @@ interface BunStorageOptions extends SqlStorageOptions {
12
12
  /** Options for `layer` and `layerProtocol`. */
13
13
  interface Options extends BunStorageOptions, Protocol.ProtocolOptions {}
14
14
  /** Official Bun SQLite host layer with one shared SqlClient/Reactivity graph. */
15
- declare const storageLayer: (options: BunStorageOptions) => Layer.Layer<import("@streamsy/core").CommitBoundary | import("@streamsy/core/internal").OwnerRecords | import("effect/reactivity/Reactivity").Reactivity | import("effect/sql/SqlClient").SqlClient | import("@streamsy/core").Storage, StorageFault, never>;
15
+ declare const storageLayer: (options: BunStorageOptions) => Layer.Layer<import("@streamsy/core").CommitBoundary | import("@streamsy/core/internal").CommitHooks | import("@streamsy/core/internal").OwnerRecords | import("@streamsy/core/internal").ProjectionMarks | import("effect/reactivity/Reactivity").Reactivity | import("effect/sql/SqlClient").SqlClient | import("@streamsy/core").Storage, StorageFault, never>;
16
16
  /** Complete persistent protocol Layer for an HttpRouter application. */
17
- declare const layerProtocol: (options: Options) => Layer.Layer<import("@streamsy/core").CommitBoundary | import("@streamsy/core/internal").OwnerRecords | import("effect/reactivity/Reactivity").Reactivity | import("effect/sql/SqlClient").SqlClient | import("@streamsy/core").Storage | import("@streamsy/core/protocol").StreamsReader | import("@streamsy/core/protocol").StreamsWriter, StorageFault, never>;
17
+ declare const layerProtocol: (options: Options) => Layer.Layer<import("@streamsy/core").CommitBoundary | import("@streamsy/core/internal").CommitHooks | import("@streamsy/core/internal").OwnerRecords | import("@streamsy/core/internal").ProjectionMarks | import("effect/reactivity/Reactivity").Reactivity | import("effect/sql/SqlClient").SqlClient | import("@streamsy/core").Storage | import("@streamsy/core/protocol").StreamsReader | import("@streamsy/core/protocol").StreamsWriter, StorageFault, never>;
18
18
  declare function layer<const Keys extends ReadonlyArray<Streamsy.Any>>(group: Streamsy.Group<Keys>, options: Options): Layer.Layer<Layer.Success<ReturnType<typeof storageLayer>> | Streamsy.Host<Streamsy.Group<Keys>>, Layer.Error<ReturnType<typeof storageLayer>>>;
19
19
  export { bun_storage_d_exports as BunStorage };
package/dist/bun.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime-D8jQpDG7.js";
2
- import { a as storageLayer$1, d as retryWithPolicy, f as transactionRetryPolicy, o as inspectFormat, s as newerVersionFault, u as sharedSqlClientLayer } from "./storage-D3Ubo2kB.js";
2
+ import { a as storageLayer$1, d as retryWithPolicy, f as transactionRetryPolicy, o as inspectFormat, s as newerVersionFault, u as sharedSqlClientLayer } from "./storage-D5lTbsiK.js";
3
3
  import { Array, Cause, Effect, FileSystem, Layer, Option, Path, Result, Schema } from "effect";
4
4
  import { StorageFault } from "@streamsy/core";
5
5
  import { Protocol } from "@streamsy/core/protocol";
@@ -26,7 +26,7 @@ const readFormat = (reader) => {
26
26
  const preflight = (filename, driver, retry) => Effect.gen(function* () {
27
27
  const state = yield* retryWithPolicy(driver.read(filename, readFormat), retry, (error) => error.retryable);
28
28
  yield* inspectFormat(state.names);
29
- if (state.version > 1) return yield* newerVersionFault(state.version);
29
+ if (state.version > 2) return yield* newerVersionFault(state.version);
30
30
  });
31
31
  /**
32
32
  * Prepare a SQLite file before the client opens it: create the parent folder,
@@ -1,4 +1,4 @@
1
- import { r as SqlStorageOptions } from "./storage-kHdg037u.js";
1
+ import { r as SqlStorageOptions } from "./storage-C1XYywlK.js";
2
2
  import { Layer } from "effect";
3
3
  import { Streamsy } from "@streamsy/core";
4
4
  import { Protocol } from "@streamsy/core/protocol";
@@ -16,8 +16,8 @@ declare const DEFAULT_LONG_POLL_TIMEOUT_MS = 25000;
16
16
  /** Options for `layer` and `layerProtocol`. */
17
17
  interface Options extends DurableObjectStorageOptions, Protocol.ProtocolOptions {}
18
18
  /** Official local/host Durable Object SQLite layer with commit-boundary support. */
19
- declare const storageLayer: (options: DurableObjectStorageOptions) => Layer.Layer<import("@streamsy/core").CommitBoundary | import("@streamsy/core/internal").OwnerRecords | import("effect/reactivity/Reactivity").Reactivity | import("effect/sql/SqlClient").SqlClient | import("@streamsy/core").Storage, import("@streamsy/core").StorageFault, never>;
19
+ declare const storageLayer: (options: DurableObjectStorageOptions) => Layer.Layer<import("@streamsy/core").CommitBoundary | import("@streamsy/core/internal").CommitHooks | import("@streamsy/core/internal").OwnerRecords | import("@streamsy/core/internal").ProjectionMarks | import("effect/reactivity/Reactivity").Reactivity | import("effect/sql/SqlClient").SqlClient | import("@streamsy/core").Storage, import("@streamsy/core").StorageFault, never>;
20
20
  /** Protocol over the official Durable Object SQLite layer. */
21
- declare const layerProtocol: (options: Options) => Layer.Layer<import("@streamsy/core").CommitBoundary | import("@streamsy/core/internal").OwnerRecords | import("effect/reactivity/Reactivity").Reactivity | import("effect/sql/SqlClient").SqlClient | import("@streamsy/core").Storage | import("@streamsy/core/protocol").StreamsReader | import("@streamsy/core/protocol").StreamsWriter, import("@streamsy/core").StorageFault, never>;
21
+ declare const layerProtocol: (options: Options) => Layer.Layer<import("@streamsy/core").CommitBoundary | import("@streamsy/core/internal").CommitHooks | import("@streamsy/core/internal").OwnerRecords | import("@streamsy/core/internal").ProjectionMarks | import("effect/reactivity/Reactivity").Reactivity | import("effect/sql/SqlClient").SqlClient | import("@streamsy/core").Storage | import("@streamsy/core/protocol").StreamsReader | import("@streamsy/core/protocol").StreamsWriter, import("@streamsy/core").StorageFault, never>;
22
22
  declare function layer<const Keys extends ReadonlyArray<Streamsy.Any>>(group: Streamsy.Group<Keys>, options: Options): Layer.Layer<Layer.Success<ReturnType<typeof storageLayer>> | Streamsy.Host<Streamsy.Group<Keys>>, Layer.Error<ReturnType<typeof storageLayer>>>;
23
23
  export { durable_object_storage_d_exports as DurableObjectStorage };
@@ -1,5 +1,5 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime-D8jQpDG7.js";
2
- import { i as layerWithDurableObjectTransactions, u as sharedSqlClientLayer } from "./storage-D3Ubo2kB.js";
2
+ import { i as layerWithDurableObjectTransactions, u as sharedSqlClientLayer } from "./storage-D5lTbsiK.js";
3
3
  import { Layer } from "effect";
4
4
  import "@streamsy/core";
5
5
  import { Protocol } from "@streamsy/core/protocol";
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { a as layerProtocol, c as DEFAULT_TRANSACTION_RETRY_DELAY_MS, i as layer, n as Options, o as clientLayer, s as DEFAULT_TRANSACTION_RETRY_ATTEMPTS, t as DEFAULT_REPAIR_INTERVAL_MS } from "./storage-kHdg037u.js";
1
+ import { a as layerProtocol, c as DEFAULT_TRANSACTION_RETRY_DELAY_MS, i as layer, n as Options, o as clientLayer, s as DEFAULT_TRANSACTION_RETRY_ATTEMPTS, t as DEFAULT_REPAIR_INTERVAL_MS } from "./storage-C1XYywlK.js";
2
2
  declare namespace sql_storage_d_exports {
3
3
  export { DEFAULT_REPAIR_INTERVAL_MS, DEFAULT_TRANSACTION_RETRY_ATTEMPTS, DEFAULT_TRANSACTION_RETRY_DELAY_MS, Options, clientLayer, layer, layerProtocol };
4
4
  }
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime-D8jQpDG7.js";
2
- import { l as clientLayer, n as layer, r as layerProtocol, t as DEFAULT_REPAIR_INTERVAL_MS } from "./storage-D3Ubo2kB.js";
2
+ import { l as clientLayer, n as layer, r as layerProtocol, t as DEFAULT_REPAIR_INTERVAL_MS } from "./storage-D5lTbsiK.js";
3
3
  // src/sql-storage.ts
4
4
  var sql_storage_exports = /* @__PURE__ */ __exportAll({
5
5
  DEFAULT_REPAIR_INTERVAL_MS: () => DEFAULT_REPAIR_INTERVAL_MS,
@@ -1,8 +1,8 @@
1
- import { Context, Effect, Layer, Queue, Scope, Stream } from "effect";
1
+ import { Context, Effect, Layer, PubSub, Queue, Scope, Stream } from "effect";
2
2
  import { Reactivity } from "effect/reactivity";
3
3
  import { SqlClient, SqlError } from "effect/sql";
4
- import { OwnerRecords } from "@streamsy/core/internal";
5
- import { CommitBoundary, Storage, StorageFault, Streamsy } from "@streamsy/core";
4
+ import { CommitHooks, OwnerRecords, ProjectionMarks } from "@streamsy/core/internal";
5
+ import { CommitBoundary, Storage, StorageFault, StreamId, Streamsy } from "@streamsy/core";
6
6
  import { Protocol } from "@streamsy/core/protocol";
7
7
  declare const DEFAULT_TRANSACTION_RETRY_ATTEMPTS = 16;
8
8
  declare const DEFAULT_TRANSACTION_RETRY_DELAY_MS = 25;
@@ -27,10 +27,10 @@ interface SqlStorageOptions {
27
27
  readonly transactionRetryDelayMs?: number;
28
28
  }
29
29
  /** Generic SQLite-stream SQL layer. Hosts supply one matching SqlClient/Reactivity graph. */
30
- declare const storageLayer: (options?: SqlStorageOptions) => Layer.Layer<Storage | CommitBoundary | OwnerRecords, StorageFault, SqlClient.SqlClient | Reactivity.Reactivity>;
30
+ declare const storageLayer: (options?: SqlStorageOptions) => Layer.Layer<Storage | CommitBoundary | OwnerRecords | CommitHooks | ProjectionMarks, StorageFault, SqlClient.SqlClient | Reactivity.Reactivity>;
31
31
  /** Options for `layer` and `layerProtocol`. */
32
32
  interface Options extends SqlStorageOptions, Protocol.ProtocolOptions {}
33
33
  /** Protocol over SQL storage. */
34
- declare const layerProtocol: (options?: Options) => Layer.Layer<CommitBoundary | OwnerRecords | Storage | import("@streamsy/core/protocol").StreamsReader | import("@streamsy/core/protocol").StreamsWriter, StorageFault, Reactivity.Reactivity | SqlClient.SqlClient>;
34
+ declare const layerProtocol: (options?: Options) => Layer.Layer<CommitBoundary | CommitHooks | OwnerRecords | ProjectionMarks | Storage | import("@streamsy/core/protocol").StreamsReader | import("@streamsy/core/protocol").StreamsWriter, StorageFault, Reactivity.Reactivity | SqlClient.SqlClient>;
35
35
  declare function layer<const Keys extends ReadonlyArray<Streamsy.Any>>(group: Streamsy.Group<Keys>, options?: Options): Layer.Layer<Layer.Success<ReturnType<typeof storageLayer>> | Streamsy.Host<Streamsy.Group<Keys>>, Layer.Error<ReturnType<typeof storageLayer>>, Layer.Services<ReturnType<typeof storageLayer>>>;
36
36
  export { layerProtocol as a, DEFAULT_TRANSACTION_RETRY_DELAY_MS as c, layer as i, Options as n, clientLayer as o, SqlStorageOptions as r, DEFAULT_TRANSACTION_RETRY_ATTEMPTS as s, DEFAULT_REPAIR_INTERVAL_MS as t };
@@ -1,7 +1,7 @@
1
- import { Array, Clock, Context, Deferred, Effect, Fiber, Layer, Option, Order, Predicate, Queue, Schedule, Scheduler, Schema, Stream } from "effect";
1
+ import { Array, Clock, Context, Deferred, Effect, Fiber, Layer, Option, Order, Predicate, PubSub, Queue, Schedule, Scheduler, Schema, Stream } from "effect";
2
2
  import { Reactivity } from "effect/reactivity";
3
3
  import { SqlClient, SqlError } from "effect/sql";
4
- import { OwnerRecords, attachOwner, ownerOf } from "@streamsy/core/internal";
4
+ import { CommitHooks, MAX_PRODUCER_ID_LENGTH, OwnerRecords, ProjectionMarks, attachOwner, commitHookRegistry, fitKey, ownerOf } from "@streamsy/core/internal";
5
5
  import { CommitBoundary, MutationRejected, Mutations, Offset, OperationResults, Storage, StorageFault, StreamId, ZERO_OFFSET } from "@streamsy/core";
6
6
  import { Protocol } from "@streamsy/core/protocol";
7
7
  const transactionRetryPolicy = (options) => {
@@ -41,7 +41,9 @@ const makeBoundary = (repairIntervalMs, transactionRetry, probe, transactionMaxO
41
41
  const sql = yield* SqlClient.SqlClient;
42
42
  const reactivity = yield* Reactivity.Reactivity;
43
43
  const subscriptions = /* @__PURE__ */ new Set();
44
+ const feed = yield* Effect.acquireRelease(PubSub.unbounded(), PubSub.shutdown);
44
45
  let closed = false;
46
+ const hooks = commitHookRegistry();
45
47
  yield* Effect.addFinalizer(() => Effect.gen(function* () {
46
48
  const active = yield* Effect.sync(() => {
47
49
  closed = true;
@@ -52,17 +54,20 @@ const makeBoundary = (repairIntervalMs, transactionRetry, probe, transactionMaxO
52
54
  }));
53
55
  const ownedTransaction = (body) => Effect.suspend(() => {
54
56
  if (probe !== void 0) probe.transactionAttempts += 1;
55
- const pending = /* @__PURE__ */ new Set();
57
+ const pending = {
58
+ keys: /* @__PURE__ */ new Set(),
59
+ ids: /* @__PURE__ */ new Set()
60
+ };
56
61
  let bodyCompleted = false;
57
- const transactionBody = body.pipe(Effect.provideService(PendingInvalidations, pending), Effect.tap(() => Effect.sync(() => {
62
+ const transactionBody = body.pipe(Effect.tap(() => hooks.run(pending.ids)), Effect.provideService(PendingInvalidations, pending), Effect.tap(() => Effect.sync(() => {
58
63
  bodyCompleted = true;
59
64
  })));
60
65
  const transaction = sql.withTransaction(transactionBody).pipe(Effect.catchIf((error) => bodyCompleted && SqlError.isSqlError(error), (error) => Effect.die(error)));
61
66
  return (transactionMaxOpsBeforeYield === void 0 ? transaction : transaction.pipe(Effect.provideService(Scheduler.MaxOpsBeforeYield, transactionMaxOpsBeforeYield))).pipe(Effect.tap(() => {
62
- if (pending.size === 0) return Effect.void;
67
+ if (pending.keys.size === 0) return Effect.void;
63
68
  if (probe !== void 0) probe.invalidations += 1;
64
- return reactivity.invalidate([...pending]);
65
- }));
69
+ return reactivity.invalidate([...pending.keys]);
70
+ }), Effect.tap(() => pending.ids.size === 0 ? Effect.void : PubSub.publish(feed, [...pending.ids])));
66
71
  });
67
72
  const withTransaction = (body) => Effect.flatMap(Effect.serviceOption(PendingInvalidations), (owned) => {
68
73
  if (Option.isSome(owned)) return body;
@@ -71,12 +76,13 @@ const makeBoundary = (repairIntervalMs, transactionRetry, probe, transactionMaxO
71
76
  return ownedTransaction(body);
72
77
  });
73
78
  });
74
- const mutation = ({ keys, effect, retryable }) => Effect.flatMap(Effect.serviceOption(sql.transactionService), (ambient) => Effect.flatMap(Effect.serviceOption(PendingInvalidations), (pendingBefore) => {
79
+ const mutation = ({ keys, ids, effect, retryable }) => Effect.flatMap(Effect.serviceOption(sql.transactionService), (ambient) => Effect.flatMap(Effect.serviceOption(PendingInvalidations), (pendingBefore) => {
75
80
  if (Option.isSome(ambient) && Option.isNone(pendingBefore)) return Effect.die(/* @__PURE__ */ new Error("Ambient SQL transaction is not owned by CommitBoundary"));
76
81
  const run = Effect.tap(effect, () => Effect.flatMap(Effect.serviceOption(PendingInvalidations), (pending) => {
77
82
  if (Option.isNone(pending)) return Effect.die(/* @__PURE__ */ new Error("CommitBoundary owner context disappeared"));
78
83
  return Effect.sync(() => {
79
- for (const key of keys) pending.value.add(key);
84
+ for (const key of keys) pending.value.keys.add(key);
85
+ for (const id of ids) pending.value.ids.add(id);
80
86
  });
81
87
  }));
82
88
  return Option.isSome(ambient) ? run : retryWithPolicy(ownedTransaction(run), transactionRetry, (error) => SqlError.isSqlError(error) ? error.isRetryable : retryable(error));
@@ -134,7 +140,9 @@ const makeBoundary = (repairIntervalMs, transactionRetry, probe, transactionMaxO
134
140
  return {
135
141
  withTransaction,
136
142
  mutation,
137
- changes
143
+ changes,
144
+ committed: PubSub.subscribe(feed),
145
+ beforeCommit: hooks.beforeCommit
138
146
  };
139
147
  });
140
148
  const boundaryLayer = (repairIntervalMs, transactionRetry, probe, transactionMaxOpsBeforeYield) => Layer.effect(BoundaryRuntimeService, makeBoundary(repairIntervalMs, transactionRetry, probe, transactionMaxOpsBeforeYield));
@@ -158,7 +166,8 @@ const STORAGE_TABLES = [
158
166
  "streamsy_streams",
159
167
  "streamsy_messages",
160
168
  "streamsy_producers",
161
- "streamsy_owner_records"
169
+ "streamsy_owner_records",
170
+ "streamsy_projection_pending"
162
171
  ];
163
172
  const STORAGE_VERSION_TABLE_DDL = {
164
173
  sqlite: `CREATE TABLE ${STORAGE_VERSION_TABLE} (version INTEGER PRIMARY KEY, applied_at_ms INTEGER NOT NULL)`,
@@ -252,6 +261,23 @@ const STORAGE_MIGRATIONS = [{
252
261
  owner_key VARCHAR(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin PRIMARY KEY, owner_value LONGTEXT NOT NULL
253
262
  ) ENGINE=InnoDB`
254
263
  ]
264
+ }, {
265
+ sqlite: [`CREATE TABLE streamsy_projection_pending (
266
+ projection TEXT NOT NULL, params TEXT NOT NULL, seq INTEGER NOT NULL,
267
+ attempts INTEGER NOT NULL DEFAULT 0, due_at INTEGER NOT NULL DEFAULT 0,
268
+ PRIMARY KEY(projection, params)
269
+ )`, `CREATE INDEX idx_streamsy_projection_pending_due ON streamsy_projection_pending(projection, due_at)`],
270
+ pg: [`CREATE TABLE streamsy_projection_pending (
271
+ projection TEXT COLLATE "C" NOT NULL, params TEXT COLLATE "C" NOT NULL, seq BIGINT NOT NULL,
272
+ attempts INTEGER NOT NULL DEFAULT 0, due_at BIGINT NOT NULL DEFAULT 0,
273
+ PRIMARY KEY(projection, params)
274
+ )`, `CREATE INDEX idx_streamsy_projection_pending_due ON streamsy_projection_pending(projection, due_at)`],
275
+ mysql: [`CREATE TABLE IF NOT EXISTS streamsy_projection_pending (
276
+ projection VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin NOT NULL, params VARCHAR(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin NOT NULL,
277
+ seq BIGINT NOT NULL, attempts INT NOT NULL DEFAULT 0, due_at BIGINT NOT NULL DEFAULT 0,
278
+ PRIMARY KEY(projection, params),
279
+ INDEX idx_streamsy_projection_pending_due (projection, due_at)
280
+ ) ENGINE=InnoDB`]
255
281
  }];
256
282
  // src/migrations.ts
257
283
  /** Fixed `pg_advisory_xact_lock` key: "streamsy" in ASCII, read as a 64-bit integer. */
@@ -259,13 +285,13 @@ const PG_MIGRATION_LOCK_KEY = "8319400191411975033";
259
285
  /** Named lock that serialises MySQL migrations, whose DDL cannot share one transaction. */
260
286
  const MYSQL_MIGRATION_LOCK = STORAGE_VERSION_TABLE;
261
287
  const MYSQL_MIGRATION_LOCK_TIMEOUT_SECONDS = 30;
262
- const fault = (operation, error) => new StorageFault({
288
+ const fault$1 = (operation, error) => new StorageFault({
263
289
  operation,
264
290
  message: error.message,
265
291
  retryable: error.isRetryable,
266
292
  cause: error
267
293
  });
268
- const sqlAttempt = (operation, effect) => effect.pipe(Effect.mapError((error) => fault(operation, error)));
294
+ const sqlAttempt = (operation, effect) => effect.pipe(Effect.mapError((error) => fault$1(operation, error)));
269
295
  /** Return whether the database already holds this format, or fail on an older format. */
270
296
  const inspectFormat = (tables) => {
271
297
  const ownsFormat = tables.has(STORAGE_VERSION_TABLE);
@@ -277,7 +303,7 @@ const inspectFormat = (tables) => {
277
303
  };
278
304
  const newerVersionFault = (version) => new StorageFault({
279
305
  operation: "migration.open",
280
- message: `Unsupported newer Streamsy storage schema version ${version}; supported version is 1`,
306
+ message: `Unsupported newer Streamsy storage schema version ${version}; supported version is 2`,
281
307
  retryable: false
282
308
  });
283
309
  const listTables = (sql, dialect) => sqlAttempt("migration.inspect", {
@@ -290,8 +316,8 @@ const listTables = (sql, dialect) => sqlAttempt("migration.inspect", {
290
316
  const migrateVersions = (sql, dialect, hooks) => Effect.gen(function* () {
291
317
  if (!(yield* inspectFormat(yield* listTables(sql, dialect)))) yield* sqlAttempt("migration.create-version-table", sql.unsafe(STORAGE_VERSION_TABLE_DDL[dialect]).pipe(Effect.asVoid));
292
318
  const current = yield* sqlAttempt("migration.read-version", sql`SELECT MAX(version) AS version FROM ${sql(STORAGE_VERSION_TABLE)}`.pipe(Effect.map((rows) => Number(rows[0]?.version ?? 0))));
293
- if (current > 1) return yield* newerVersionFault(current);
294
- for (let version = current + 1; version <= 1; version++) {
319
+ if (current > 2) return yield* newerVersionFault(current);
320
+ for (let version = current + 1; version <= 2; version++) {
295
321
  if (hooks.beforeVersion !== void 0) yield* hooks.beforeVersion(version);
296
322
  const statements = STORAGE_MIGRATIONS[version - 1]?.[dialect];
297
323
  if (statements === void 0) return yield* Effect.die(/* @__PURE__ */ new Error("Missing migration"));
@@ -326,7 +352,86 @@ const migrate = (sql, hooks = {}, transactionRetry = transactionRetryPolicy({}))
326
352
  hooks.onTransactionAttempt?.();
327
353
  if (dialect === "mysql") return migrateMysql(sql, hooks);
328
354
  return sql.withTransaction(dialect === "pg" ? sqlAttempt("migration.lock", sql`SELECT pg_advisory_xact_lock(${sql.literal(PG_MIGRATION_LOCK_KEY)})`).pipe(Effect.andThen(migrateVersions(sql, dialect, hooks))) : migrateVersions(sql, dialect, hooks));
329
- }), transactionRetry, (error) => SqlError.isSqlError(error) ? error.isRetryable : error.retryable).pipe(Effect.catchIf(SqlError.isSqlError, (error) => Effect.fail(fault("migration.transaction", error)))));
355
+ }), transactionRetry, (error) => SqlError.isSqlError(error) ? error.isRetryable : error.retryable).pipe(Effect.catchIf(SqlError.isSqlError, (error) => Effect.fail(fault$1("migration.transaction", error)))));
356
+ // src/projection-marks.ts
357
+ const TABLE = "streamsy_projection_pending";
358
+ /**
359
+ * Durable Object SQL accepts at most 100 bound parameters in one statement. A
360
+ * marked row binds five values and a params lookup binds one, so both chunk.
361
+ */
362
+ const MARK_CHUNK = 16;
363
+ const READ_CHUNK = 64;
364
+ const fault = (operation) => (error) => new StorageFault({
365
+ operation: `marks.${operation}`,
366
+ message: error.message,
367
+ retryable: error.isRetryable,
368
+ cause: error
369
+ });
370
+ const pendingMark = (row) => ({
371
+ params: row.params,
372
+ seq: Number(row.seq),
373
+ attempts: Number(row.attempts),
374
+ dueAt: Number(row.due_at)
375
+ });
376
+ /**
377
+ * Pending marks in `streamsy_projection_pending`. Statements join the boundary
378
+ * transaction when one is open, so a mark written by a commit hook commits or
379
+ * rolls back with the write that caused it.
380
+ */
381
+ const sqlProjectionMarks = (sql) => {
382
+ const table = sql(TABLE);
383
+ const mark = (projection, params) => Effect.forEach(Array.chunksOf(params, MARK_CHUNK), (chunk) => {
384
+ const rows = sql.insert(Array.map(chunk, (value) => ({
385
+ projection,
386
+ params: value,
387
+ seq: 1,
388
+ attempts: 0,
389
+ due_at: 0
390
+ })));
391
+ return sql.onDialectOrElse({
392
+ mysql: () => sql`INSERT INTO ${table} ${rows} ON DUPLICATE KEY UPDATE seq = seq + 1`,
393
+ orElse: () => sql`INSERT INTO ${table} ${rows}
394
+ ON CONFLICT (projection, params) DO UPDATE SET seq = ${table}.seq + 1`
395
+ });
396
+ }, { discard: true });
397
+ const nextDue = (projection) => sql`SELECT MIN(due_at) AS at FROM ${table} WHERE projection = ${projection}`.pipe(Effect.map((rows) => {
398
+ const at = rows[0]?.at;
399
+ return at === null || at === void 0 ? Option.none() : Option.some(Number(at));
400
+ }), Effect.mapError(fault("nextDue")));
401
+ const due = (projection, now, limit) => sql`SELECT params, seq, attempts, due_at FROM ${table}
402
+ WHERE projection = ${projection} AND due_at <= ${now}
403
+ ORDER BY due_at, params LIMIT ${limit}`.pipe(Effect.map(Array.map(pendingMark)), Effect.mapError(fault("due")));
404
+ const read = (projection, params) => Effect.forEach(Array.chunksOf(params, READ_CHUNK), (chunk) => sql`SELECT params, seq, attempts, due_at FROM ${table}
405
+ WHERE projection = ${projection} AND ${sql.in("params", chunk)}`).pipe(Effect.map((chunks) => new Map(Array.map(chunks.flat(), (row) => [row.params, pendingMark(row)]))), Effect.mapError(fault("read")));
406
+ const clear = (projection, params, seq) => sql`DELETE FROM ${table} WHERE projection = ${projection} AND params = ${params} AND seq = ${seq}`.pipe(Effect.asVoid, Effect.mapError(fault("clear")));
407
+ const remove = (projection, params) => sql`DELETE FROM ${table} WHERE projection = ${projection} AND params = ${params}`.pipe(Effect.asVoid, Effect.mapError(fault("remove")));
408
+ const defer = (projection, params, attempts, dueAt) => {
409
+ const row = sql.insert({
410
+ projection,
411
+ params,
412
+ seq: 1,
413
+ attempts,
414
+ due_at: dueAt
415
+ });
416
+ return sql.onDialectOrElse({
417
+ mysql: () => sql`INSERT INTO ${table} ${row}
418
+ ON DUPLICATE KEY UPDATE attempts = VALUES(attempts), due_at = VALUES(due_at)`,
419
+ orElse: () => sql`INSERT INTO ${table} ${row}
420
+ ON CONFLICT (projection, params) DO UPDATE SET attempts = excluded.attempts, due_at = excluded.due_at`
421
+ }).pipe(Effect.asVoid, Effect.mapError(fault("defer")));
422
+ };
423
+ const deferred = (projection) => sql`SELECT COUNT(*) AS count FROM ${table} WHERE projection = ${projection} AND attempts > 0`.pipe(Effect.map((rows) => Number(rows[0]?.count ?? 0)), Effect.mapError(fault("deferred")));
424
+ return ProjectionMarks.of({
425
+ mark,
426
+ nextDue,
427
+ due,
428
+ read,
429
+ clear,
430
+ remove,
431
+ defer,
432
+ deferred
433
+ });
434
+ };
330
435
  // src/storage.ts
331
436
  const DEFAULT_REPAIR_INTERVAL_MS = 1e3;
332
437
  const STORAGE_KEY = "streamsy:storage";
@@ -448,7 +553,7 @@ const tooLong = (operation, label, value, limit) => {
448
553
  };
449
554
  const oversizedKey = (mutation) => {
450
555
  for (const operation of mutation.operations) {
451
- const fault = Predicate.isTagged(operation, "Create") ? tooLong("mutate.stream-id", "Stream id", operation.record.id, 512) : Predicate.isTagged(operation, "Append") && operation.producer !== void 0 ? tooLong("mutate.producer-id", "Producer id", operation.producer.producerId, 255) : Option.none();
556
+ const fault = Predicate.isTagged(operation, "Create") ? tooLong("mutate.stream-id", "Stream id", operation.record.id, 512) : Predicate.isTagged(operation, "Append") && operation.producer !== void 0 ? tooLong("mutate.producer-id", "Producer id", operation.producer.producerId, MAX_PRODUCER_ID_LENGTH) : Option.none();
452
557
  if (Option.isSome(fault)) return fault;
453
558
  }
454
559
  return Option.none();
@@ -723,6 +828,7 @@ const makeStorage = (sql, boundary) => Storage.of({
723
828
  if (Option.isSome(oversized)) return Effect.fail(oversized.value);
724
829
  return boundary.mutation({
725
830
  keys: [STORAGE_KEY],
831
+ ids,
726
832
  effect: applyMutation(sql, mutation),
727
833
  retryable: (error) => Predicate.isTagged(error, "StorageFault") && error.retryable
728
834
  }).pipe(Effect.catchIf(SqlError.isSqlError, (error) => Effect.fail(sqlFault("mutate.transaction", error))));
@@ -731,6 +837,7 @@ const makeStorage = (sql, boundary) => Storage.of({
731
837
  keys: [STORAGE_KEY],
732
838
  read: snapshot(sql, id)
733
839
  }),
840
+ committed: boundary.committed,
734
841
  nextExpiry: attempt("nextExpiry", sql`SELECT expires_at_ms AS at, stream_id FROM streamsy_streams
735
842
  WHERE expires_at_ms IS NOT NULL AND soft_deleted = 0
736
843
  ORDER BY expires_at_ms, stream_id LIMIT 1`).pipe(Effect.flatMap((rows) => rows[0] === void 0 ? Effect.succeed(Option.none()) : decodeExpiryRow(rows[0]).pipe(Effect.map(({ at, stream_id: streamId }) => Option.some({
@@ -756,6 +863,10 @@ const OWNER_RECORD_CONFLICT = "records.conflict";
756
863
  */
757
864
  const makeOwnerRecords = (sql) => {
758
865
  const absentKeys = /* @__PURE__ */ new WeakMap();
866
+ const storedKey = (key) => sql.onDialectOrElse({
867
+ mysql: () => fitKey(key, 512),
868
+ orElse: () => Effect.succeed(key)
869
+ });
759
870
  const select = (key, forUpdate) => attempt("records.read", sql`SELECT owner_value FROM streamsy_owner_records WHERE owner_key = ${key}${sql.literal(forUpdate ? " FOR UPDATE" : "")}`).pipe(Effect.map((rows) => Option.fromUndefinedOr(rows[0]?.owner_value)));
760
871
  const lockFound = (key, found) => Option.isNone(found) ? Effect.succeed(found) : sql.onDialectOrElse({
761
872
  sqlite: () => Effect.succeed(found),
@@ -783,7 +894,8 @@ const makeOwnerRecords = (sql) => {
783
894
  cause: error
784
895
  }) : sqlFault("records.write", error)), Effect.asVoid);
785
896
  return OwnerRecords.of({
786
- read: Effect.fn("SqlStorage.records.read")(function* (key) {
897
+ read: Effect.fn("SqlStorage.records.read")(function* (requested) {
898
+ const key = yield* storedKey(requested);
787
899
  const transaction = yield* Effect.serviceOption(sql.transactionService);
788
900
  if (Option.isNone(transaction)) return yield* select(key, false);
789
901
  const found = yield* Effect.flatMap(select(key, false), (first) => lockFound(key, first));
@@ -793,7 +905,8 @@ const makeOwnerRecords = (sql) => {
793
905
  else absent.delete(key);
794
906
  return found;
795
907
  }),
796
- write: Effect.fn("SqlStorage.records.write")(function* (key, encoded) {
908
+ write: Effect.fn("SqlStorage.records.write")(function* (requested, encoded) {
909
+ const key = yield* storedKey(requested);
797
910
  const transaction = yield* Effect.serviceOption(sql.transactionService);
798
911
  const absent = Option.isSome(transaction) ? absentKeys.get(transaction.value) : void 0;
799
912
  if (absent?.has(key) === true) {
@@ -801,7 +914,10 @@ const makeOwnerRecords = (sql) => {
801
914
  absent.delete(key);
802
915
  } else yield* upsert(key, encoded);
803
916
  }),
804
- remove: Effect.fn("SqlStorage.records.remove")((key) => execute("records.remove", sql`DELETE FROM streamsy_owner_records WHERE owner_key = ${key}`))
917
+ remove: Effect.fn("SqlStorage.records.remove")(function* (requested) {
918
+ const key = yield* storedKey(requested);
919
+ yield* execute("records.remove", sql`DELETE FROM streamsy_owner_records WHERE owner_key = ${key}`);
920
+ })
805
921
  });
806
922
  };
807
923
  const makeLayer = (options, probe, transactionMaxOpsBeforeYield) => {
@@ -813,7 +929,7 @@ const makeLayer = (options, probe, transactionMaxOpsBeforeYield) => {
813
929
  } }, transactionRetry);
814
930
  const boundary = yield* BoundaryRuntimeService;
815
931
  const owner = ownerOf(boundary);
816
- return Context.empty().pipe(Context.add(Storage, attachOwner(makeStorage(sql, boundary), owner)), Context.add(CommitBoundary, CommitBoundary.of(boundary)), Context.add(OwnerRecords, attachOwner(makeOwnerRecords(sql), owner)));
932
+ return Context.empty().pipe(Context.add(Storage, attachOwner(makeStorage(sql, boundary), owner)), Context.add(CommitBoundary, CommitBoundary.of(boundary)), Context.add(OwnerRecords, attachOwner(makeOwnerRecords(sql), owner)), Context.add(CommitHooks, attachOwner(CommitHooks.of({ beforeCommit: boundary.beforeCommit }), owner)), Context.add(ProjectionMarks, attachOwner(sqlProjectionMarks(sql), owner)));
817
933
  })).pipe(Layer.provide(boundaryLayer(options.repairIntervalMs ?? 1e3, transactionRetry, probe, transactionMaxOpsBeforeYield)));
818
934
  };
819
935
  /** Generic SQLite-stream SQL layer. Hosts supply one matching SqlClient/Reactivity graph. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@streamsy/storage",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Effect SQL storage for Streamsy on SQLite, Postgres and MySQL, with Bun and Durable Object SQLite host layers.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -42,7 +42,7 @@
42
42
  "test:dialects": "bun --bun vitest run test/dialects"
43
43
  },
44
44
  "dependencies": {
45
- "@streamsy/core": "^0.5.0"
45
+ "@streamsy/core": "^0.7.0"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@cloudflare/workers-types": "4.20251228.0",
@@ -52,7 +52,7 @@
52
52
  "@effect/sql-sqlite-bun": "4.0.5",
53
53
  "@effect/sql-sqlite-do": "4.0.5",
54
54
  "@effect/vitest": "4.0.5",
55
- "@streamsy/serve": "0.5.0",
55
+ "@streamsy/serve": "0.7.0",
56
56
  "@types/bun": "^1.4.3",
57
57
  "effect": "4.0.5",
58
58
  "miniflare": "4.20260730.0",