@streamsy/storage 0.6.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-Cb_kOO_g.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-DFCZf1UK.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-Cb_kOO_g.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-DFCZf1UK.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-Cb_kOO_g.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-DFCZf1UK.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,7 +1,7 @@
1
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";
4
+ import { CommitHooks, OwnerRecords, ProjectionMarks } from "@streamsy/core/internal";
5
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;
@@ -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
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) => {
@@ -43,6 +43,7 @@ const makeBoundary = (repairIntervalMs, transactionRetry, probe, transactionMaxO
43
43
  const subscriptions = /* @__PURE__ */ new Set();
44
44
  const feed = yield* Effect.acquireRelease(PubSub.unbounded(), PubSub.shutdown);
45
45
  let closed = false;
46
+ const hooks = commitHookRegistry();
46
47
  yield* Effect.addFinalizer(() => Effect.gen(function* () {
47
48
  const active = yield* Effect.sync(() => {
48
49
  closed = true;
@@ -58,7 +59,7 @@ const makeBoundary = (repairIntervalMs, transactionRetry, probe, transactionMaxO
58
59
  ids: /* @__PURE__ */ new Set()
59
60
  };
60
61
  let bodyCompleted = false;
61
- 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(() => {
62
63
  bodyCompleted = true;
63
64
  })));
64
65
  const transaction = sql.withTransaction(transactionBody).pipe(Effect.catchIf((error) => bodyCompleted && SqlError.isSqlError(error), (error) => Effect.die(error)));
@@ -140,7 +141,8 @@ const makeBoundary = (repairIntervalMs, transactionRetry, probe, transactionMaxO
140
141
  withTransaction,
141
142
  mutation,
142
143
  changes,
143
- committed: PubSub.subscribe(feed)
144
+ committed: PubSub.subscribe(feed),
145
+ beforeCommit: hooks.beforeCommit
144
146
  };
145
147
  });
146
148
  const boundaryLayer = (repairIntervalMs, transactionRetry, probe, transactionMaxOpsBeforeYield) => Layer.effect(BoundaryRuntimeService, makeBoundary(repairIntervalMs, transactionRetry, probe, transactionMaxOpsBeforeYield));
@@ -164,7 +166,8 @@ const STORAGE_TABLES = [
164
166
  "streamsy_streams",
165
167
  "streamsy_messages",
166
168
  "streamsy_producers",
167
- "streamsy_owner_records"
169
+ "streamsy_owner_records",
170
+ "streamsy_projection_pending"
168
171
  ];
169
172
  const STORAGE_VERSION_TABLE_DDL = {
170
173
  sqlite: `CREATE TABLE ${STORAGE_VERSION_TABLE} (version INTEGER PRIMARY KEY, applied_at_ms INTEGER NOT NULL)`,
@@ -258,6 +261,23 @@ const STORAGE_MIGRATIONS = [{
258
261
  owner_key VARCHAR(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin PRIMARY KEY, owner_value LONGTEXT NOT NULL
259
262
  ) ENGINE=InnoDB`
260
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`]
261
281
  }];
262
282
  // src/migrations.ts
263
283
  /** Fixed `pg_advisory_xact_lock` key: "streamsy" in ASCII, read as a 64-bit integer. */
@@ -265,13 +285,13 @@ const PG_MIGRATION_LOCK_KEY = "8319400191411975033";
265
285
  /** Named lock that serialises MySQL migrations, whose DDL cannot share one transaction. */
266
286
  const MYSQL_MIGRATION_LOCK = STORAGE_VERSION_TABLE;
267
287
  const MYSQL_MIGRATION_LOCK_TIMEOUT_SECONDS = 30;
268
- const fault = (operation, error) => new StorageFault({
288
+ const fault$1 = (operation, error) => new StorageFault({
269
289
  operation,
270
290
  message: error.message,
271
291
  retryable: error.isRetryable,
272
292
  cause: error
273
293
  });
274
- 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)));
275
295
  /** Return whether the database already holds this format, or fail on an older format. */
276
296
  const inspectFormat = (tables) => {
277
297
  const ownsFormat = tables.has(STORAGE_VERSION_TABLE);
@@ -283,7 +303,7 @@ const inspectFormat = (tables) => {
283
303
  };
284
304
  const newerVersionFault = (version) => new StorageFault({
285
305
  operation: "migration.open",
286
- 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`,
287
307
  retryable: false
288
308
  });
289
309
  const listTables = (sql, dialect) => sqlAttempt("migration.inspect", {
@@ -296,8 +316,8 @@ const listTables = (sql, dialect) => sqlAttempt("migration.inspect", {
296
316
  const migrateVersions = (sql, dialect, hooks) => Effect.gen(function* () {
297
317
  if (!(yield* inspectFormat(yield* listTables(sql, dialect)))) yield* sqlAttempt("migration.create-version-table", sql.unsafe(STORAGE_VERSION_TABLE_DDL[dialect]).pipe(Effect.asVoid));
298
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))));
299
- if (current > 1) return yield* newerVersionFault(current);
300
- 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++) {
301
321
  if (hooks.beforeVersion !== void 0) yield* hooks.beforeVersion(version);
302
322
  const statements = STORAGE_MIGRATIONS[version - 1]?.[dialect];
303
323
  if (statements === void 0) return yield* Effect.die(/* @__PURE__ */ new Error("Missing migration"));
@@ -332,7 +352,86 @@ const migrate = (sql, hooks = {}, transactionRetry = transactionRetryPolicy({}))
332
352
  hooks.onTransactionAttempt?.();
333
353
  if (dialect === "mysql") return migrateMysql(sql, hooks);
334
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));
335
- }), 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
+ };
336
435
  // src/storage.ts
337
436
  const DEFAULT_REPAIR_INTERVAL_MS = 1e3;
338
437
  const STORAGE_KEY = "streamsy:storage";
@@ -454,7 +553,7 @@ const tooLong = (operation, label, value, limit) => {
454
553
  };
455
554
  const oversizedKey = (mutation) => {
456
555
  for (const operation of mutation.operations) {
457
- 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();
458
557
  if (Option.isSome(fault)) return fault;
459
558
  }
460
559
  return Option.none();
@@ -764,6 +863,10 @@ const OWNER_RECORD_CONFLICT = "records.conflict";
764
863
  */
765
864
  const makeOwnerRecords = (sql) => {
766
865
  const absentKeys = /* @__PURE__ */ new WeakMap();
866
+ const storedKey = (key) => sql.onDialectOrElse({
867
+ mysql: () => fitKey(key, 512),
868
+ orElse: () => Effect.succeed(key)
869
+ });
767
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)));
768
871
  const lockFound = (key, found) => Option.isNone(found) ? Effect.succeed(found) : sql.onDialectOrElse({
769
872
  sqlite: () => Effect.succeed(found),
@@ -791,7 +894,8 @@ const makeOwnerRecords = (sql) => {
791
894
  cause: error
792
895
  }) : sqlFault("records.write", error)), Effect.asVoid);
793
896
  return OwnerRecords.of({
794
- read: Effect.fn("SqlStorage.records.read")(function* (key) {
897
+ read: Effect.fn("SqlStorage.records.read")(function* (requested) {
898
+ const key = yield* storedKey(requested);
795
899
  const transaction = yield* Effect.serviceOption(sql.transactionService);
796
900
  if (Option.isNone(transaction)) return yield* select(key, false);
797
901
  const found = yield* Effect.flatMap(select(key, false), (first) => lockFound(key, first));
@@ -801,7 +905,8 @@ const makeOwnerRecords = (sql) => {
801
905
  else absent.delete(key);
802
906
  return found;
803
907
  }),
804
- 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);
805
910
  const transaction = yield* Effect.serviceOption(sql.transactionService);
806
911
  const absent = Option.isSome(transaction) ? absentKeys.get(transaction.value) : void 0;
807
912
  if (absent?.has(key) === true) {
@@ -809,7 +914,10 @@ const makeOwnerRecords = (sql) => {
809
914
  absent.delete(key);
810
915
  } else yield* upsert(key, encoded);
811
916
  }),
812
- 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
+ })
813
921
  });
814
922
  };
815
923
  const makeLayer = (options, probe, transactionMaxOpsBeforeYield) => {
@@ -821,7 +929,7 @@ const makeLayer = (options, probe, transactionMaxOpsBeforeYield) => {
821
929
  } }, transactionRetry);
822
930
  const boundary = yield* BoundaryRuntimeService;
823
931
  const owner = ownerOf(boundary);
824
- 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)));
825
933
  })).pipe(Layer.provide(boundaryLayer(options.repairIntervalMs ?? 1e3, transactionRetry, probe, transactionMaxOpsBeforeYield)));
826
934
  };
827
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.6.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.6.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.6.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",