@lika85456/s3qlite 0.1.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.
Files changed (117) hide show
  1. package/cjs/effect.js +18 -0
  2. package/cjs/effect.js.map +1 -0
  3. package/cjs/index.js +40 -0
  4. package/cjs/index.js.map +1 -0
  5. package/cjs/package.json +3 -0
  6. package/cjs/src/batches.js +39 -0
  7. package/cjs/src/batches.js.map +1 -0
  8. package/cjs/src/cdc/apply.js +404 -0
  9. package/cjs/src/cdc/apply.js.map +1 -0
  10. package/cjs/src/cdc/extract.js +38 -0
  11. package/cjs/src/cdc/extract.js.map +1 -0
  12. package/cjs/src/cdc/protobuf.js +135 -0
  13. package/cjs/src/cdc/protobuf.js.map +1 -0
  14. package/cjs/src/cdc/testUtils.js +49 -0
  15. package/cjs/src/cdc/testUtils.js.map +1 -0
  16. package/cjs/src/cdc/truncate.js +7 -0
  17. package/cjs/src/cdc/truncate.js.map +1 -0
  18. package/cjs/src/cdc/types.js +10 -0
  19. package/cjs/src/cdc/types.js.map +1 -0
  20. package/cjs/src/cdc/withoutCDC.js +7 -0
  21. package/cjs/src/cdc/withoutCDC.js.map +1 -0
  22. package/cjs/src/connection.js +128 -0
  23. package/cjs/src/connection.js.map +1 -0
  24. package/cjs/src/contexts.js +11 -0
  25. package/cjs/src/contexts.js.map +1 -0
  26. package/cjs/src/index.js +30 -0
  27. package/cjs/src/index.js.map +1 -0
  28. package/cjs/src/kv/fileKV.js +131 -0
  29. package/cjs/src/kv/fileKV.js.map +1 -0
  30. package/cjs/src/kv/kv.js +8 -0
  31. package/cjs/src/kv/kv.js.map +1 -0
  32. package/cjs/src/kv/memoryKV.js +16 -0
  33. package/cjs/src/kv/memoryKV.js.map +1 -0
  34. package/cjs/src/kv/s3KV.js +283 -0
  35. package/cjs/src/kv/s3KV.js.map +1 -0
  36. package/cjs/src/kv/syncFiles.js +32 -0
  37. package/cjs/src/kv/syncFiles.js.map +1 -0
  38. package/cjs/src/pull.js +101 -0
  39. package/cjs/src/pull.js.map +1 -0
  40. package/cjs/src/push.js +58 -0
  41. package/cjs/src/push.js.map +1 -0
  42. package/cjs/src/storage.js +41 -0
  43. package/cjs/src/storage.js.map +1 -0
  44. package/cjs/src/types.js +3 -0
  45. package/cjs/src/types.js.map +1 -0
  46. package/cjs/src/wrapDatabase.js +80 -0
  47. package/cjs/src/wrapDatabase.js.map +1 -0
  48. package/dts/effect.d.ts +1 -0
  49. package/dts/index.d.ts +16 -0
  50. package/dts/src/batches.d.ts +10 -0
  51. package/dts/src/cdc/apply.d.ts +14 -0
  52. package/dts/src/cdc/extract.d.ts +8 -0
  53. package/dts/src/cdc/protobuf.d.ts +3 -0
  54. package/dts/src/cdc/testUtils.d.ts +19 -0
  55. package/dts/src/cdc/truncate.d.ts +6 -0
  56. package/dts/src/cdc/types.d.ts +35 -0
  57. package/dts/src/cdc/withoutCDC.d.ts +3 -0
  58. package/dts/src/connection.d.ts +5 -0
  59. package/dts/src/contexts.d.ts +22 -0
  60. package/dts/src/index.d.ts +12 -0
  61. package/dts/src/kv/fileKV.d.ts +5 -0
  62. package/dts/src/kv/kv.d.ts +42 -0
  63. package/dts/src/kv/memoryKV.d.ts +5 -0
  64. package/dts/src/kv/s3KV.d.ts +4 -0
  65. package/dts/src/kv/syncFiles.d.ts +4 -0
  66. package/dts/src/pull.d.ts +8 -0
  67. package/dts/src/push.d.ts +4 -0
  68. package/dts/src/storage.d.ts +22 -0
  69. package/dts/src/types.d.ts +38 -0
  70. package/dts/src/wrapDatabase.d.ts +1 -0
  71. package/esm/effect.js +2 -0
  72. package/esm/effect.js.map +1 -0
  73. package/esm/index.js +22 -0
  74. package/esm/index.js.map +1 -0
  75. package/esm/src/batches.js +34 -0
  76. package/esm/src/batches.js.map +1 -0
  77. package/esm/src/cdc/apply.js +398 -0
  78. package/esm/src/cdc/apply.js.map +1 -0
  79. package/esm/src/cdc/extract.js +33 -0
  80. package/esm/src/cdc/extract.js.map +1 -0
  81. package/esm/src/cdc/protobuf.js +127 -0
  82. package/esm/src/cdc/protobuf.js.map +1 -0
  83. package/esm/src/cdc/testUtils.js +42 -0
  84. package/esm/src/cdc/testUtils.js.map +1 -0
  85. package/esm/src/cdc/truncate.js +3 -0
  86. package/esm/src/cdc/truncate.js.map +1 -0
  87. package/esm/src/cdc/types.js +7 -0
  88. package/esm/src/cdc/types.js.map +1 -0
  89. package/esm/src/cdc/withoutCDC.js +3 -0
  90. package/esm/src/cdc/withoutCDC.js.map +1 -0
  91. package/esm/src/connection.js +123 -0
  92. package/esm/src/connection.js.map +1 -0
  93. package/esm/src/contexts.js +6 -0
  94. package/esm/src/contexts.js.map +1 -0
  95. package/esm/src/index.js +12 -0
  96. package/esm/src/index.js.map +1 -0
  97. package/esm/src/kv/fileKV.js +127 -0
  98. package/esm/src/kv/fileKV.js.map +1 -0
  99. package/esm/src/kv/kv.js +4 -0
  100. package/esm/src/kv/kv.js.map +1 -0
  101. package/esm/src/kv/memoryKV.js +12 -0
  102. package/esm/src/kv/memoryKV.js.map +1 -0
  103. package/esm/src/kv/s3KV.js +279 -0
  104. package/esm/src/kv/s3KV.js.map +1 -0
  105. package/esm/src/kv/syncFiles.js +27 -0
  106. package/esm/src/kv/syncFiles.js.map +1 -0
  107. package/esm/src/pull.js +97 -0
  108. package/esm/src/pull.js.map +1 -0
  109. package/esm/src/push.js +54 -0
  110. package/esm/src/push.js.map +1 -0
  111. package/esm/src/storage.js +26 -0
  112. package/esm/src/storage.js.map +1 -0
  113. package/esm/src/types.js +2 -0
  114. package/esm/src/types.js.map +1 -0
  115. package/esm/src/wrapDatabase.js +76 -0
  116. package/esm/src/wrapDatabase.js.map +1 -0
  117. package/package.json +35 -0
@@ -0,0 +1,22 @@
1
+ import { Context, Effect, Option } from "effect";
2
+ import type { CloneTrait, ConnectDatabaseTrait, KV } from "./kv/kv.js";
3
+ declare const LocalKV_base: Context.TagClass<LocalKV, "s3qlite/LocalKV", KV & CloneTrait & ConnectDatabaseTrait>;
4
+ export declare class LocalKV extends LocalKV_base {
5
+ }
6
+ declare const RemoteKV_base: Context.TagClass<RemoteKV, "s3qlite/RemoteKV", KV>;
7
+ export declare class RemoteKV extends RemoteKV_base {
8
+ }
9
+ export declare const headKey: (dbName: string) => string;
10
+ export declare const batchKey: (batchId: string) => string;
11
+ export declare const snapshotKey: (snapshotId: string) => string;
12
+ export declare const dbKey: (dbName: string) => string;
13
+ export declare const walKey: (dbName: string) => string;
14
+ export declare const shmKey: (dbName: string) => string;
15
+ export declare const baseKey: (snapshotId: string, batchId: string) => string;
16
+ export declare const encodeJson: <A>(value: A) => Uint8Array;
17
+ export declare const decodeJson: <A>(value: Uint8Array) => Effect.Effect<A, SyntaxError>;
18
+ export declare const getJson: <A>(kv: KV, key: string) => Effect.Effect<Option.Option<{
19
+ value: A;
20
+ etag: string;
21
+ }>, SyntaxError>;
22
+ export {};
@@ -0,0 +1,38 @@
1
+ import type { Database } from "@tursodatabase/database";
2
+ import type { Effect } from "effect";
3
+ export type Snapshot = {
4
+ id: string;
5
+ baseSnapshotId?: string;
6
+ batchIdsApplied: string[];
7
+ };
8
+ export type Batch = {
9
+ id: string;
10
+ };
11
+ export type Head = {
12
+ snapshots: Snapshot[];
13
+ batches: Batch[];
14
+ };
15
+ export type PendingBatch = {
16
+ id: string;
17
+ lastChangeId: number;
18
+ };
19
+ export type StoredHead = {
20
+ head: Head;
21
+ /**
22
+ * The latest etag of the remote head this instance has been synced to.
23
+ */
24
+ remoteEtag: string;
25
+ lastSyncedLocalChangeId: number;
26
+ pendingBatches?: PendingBatch[];
27
+ };
28
+ export type ConnectionOptions = {
29
+ bucket: string;
30
+ localDirectory?: string;
31
+ };
32
+ export type S3qliteDatabase = Database & {
33
+ pull: () => Effect.Effect<void, Error>;
34
+ push: () => Effect.Effect<void, Error>;
35
+ sync: () => Effect.Effect<void, Error>;
36
+ fork: (dbName: string) => Effect.Effect<void, Error>;
37
+ checkpoint: () => Effect.Effect<void, Error>;
38
+ };
@@ -0,0 +1 @@
1
+ export declare const wrapDatabase: <T extends object>(getTarget: () => T, wrap: <R>(call: () => R, method: PropertyKey, args: readonly unknown[]) => R) => T;
package/esm/effect.js ADDED
@@ -0,0 +1,2 @@
1
+ export * from "./src/index.js";
2
+ //# sourceMappingURL=effect.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"effect.js","sourceRoot":"","sources":["../../effect.ts"],"names":[],"mappings":"AAAA,cAAc,gBAAgB,CAAC","sourcesContent":["export * from \"./src/index.js\";"]}
package/esm/index.js ADDED
@@ -0,0 +1,22 @@
1
+ import { S3 } from "@effect-aws/client-s3";
2
+ import { layer as fileSystemLayer } from "@effect/platform-node/NodeFileSystem";
3
+ import { layer as pathLayer } from "@effect/platform-node/NodePath";
4
+ import { Effect } from "effect";
5
+ import { connect as connectEffect } from "./src/index.js";
6
+ const toPromiseDatabase = (database) => {
7
+ const pull = database.pull.bind(database);
8
+ const push = database.push.bind(database);
9
+ const sync = database.sync.bind(database);
10
+ const fork = database.fork.bind(database);
11
+ const checkpoint = database.checkpoint.bind(database);
12
+ return Object.assign(database, {
13
+ pull: () => Effect.runPromise(pull()),
14
+ push: () => Effect.runPromise(push()),
15
+ sync: () => Effect.runPromise(sync()),
16
+ fork: (dbName) => Effect.runPromise(fork(dbName)),
17
+ checkpoint: () => Effect.runPromise(checkpoint()),
18
+ });
19
+ };
20
+ export const connect = async (dbName, { s3, ...connectionOptions }) => Effect.runPromise(Effect.scoped(connectEffect(dbName, connectionOptions).pipe(Effect.provide(fileSystemLayer), Effect.provide(pathLayer), Effect.provide(S3.layer(s3 ?? {})), Effect.map(toPromiseDatabase))));
21
+ export * from "./src/index.js";
22
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,EAAE,EAAE,MAAM,uBAAuB,CAAC;AAC3C,OAAO,EAAE,KAAK,IAAI,eAAe,EAAE,MAAM,sCAAsC,CAAC;AAChF,OAAO,EAAE,KAAK,IAAI,SAAS,EAAE,MAAM,gCAAgC,CAAC;AAEpE,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAEhC,OAAO,EAAE,OAAO,IAAI,aAAa,EAAE,MAAM,aAAa,CAAC;AAiBvD,MAAM,iBAAiB,GAAG,CAAC,QAAyB,EAA0B,EAAE;IAC/E,MAAM,IAAI,GAAG,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;IAC1C,MAAM,IAAI,GAAG,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;IAC1C,MAAM,IAAI,GAAG,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;IAC1C,MAAM,IAAI,GAAG,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;IAC1C,MAAM,UAAU,GAAG,QAAQ,CAAC,UAAU,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;IAEtD,OAAO,MAAM,CAAC,MAAM,CAAC,QAAoB,EAAE;QAC1C,IAAI,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,UAAU,CAAC,IAAI,EAAE,CAAC;QACrC,IAAI,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,UAAU,CAAC,IAAI,EAAE,CAAC;QACrC,IAAI,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,UAAU,CAAC,IAAI,EAAE,CAAC;QACrC,IAAI,EAAE,CAAC,MAAc,EAAE,EAAE,CAAC,MAAM,CAAC,UAAU,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;QACzD,UAAU,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,UAAU,CAAC,UAAU,EAAE,CAAC;KACjD,CAA2B,CAAC;AAC9B,CAAC,CAAC;AAEF,MAAM,CAAC,MAAM,OAAO,GAAG,KAAK,EAC3B,MAAc,EACd,EAAE,EAAE,EAAE,GAAG,iBAAiB,EAA4B,EACpB,EAAE,CACpC,MAAM,CAAC,UAAU,CAChB,MAAM,CAAC,MAAM,CACZ,aAAa,CAAC,MAAM,EAAE,iBAAiB,CAAC,CAAC,IAAI,CAC5C,MAAM,CAAC,OAAO,CAAC,eAAe,CAAC,EAC/B,MAAM,CAAC,OAAO,CAAC,SAAS,CAAC,EACzB,MAAM,CAAC,OAAO,CAAC,EAAE,CAAC,KAAK,CAAC,EAAE,IAAI,EAAE,CAAC,CAAC,EAClC,MAAM,CAAC,GAAG,CAAC,iBAAiB,CAAC,CAC7B,CACD,CACD,CAAC;AAEH,cAAc,gBAAgB,CAAC","sourcesContent":["import { S3 } from \"@effect-aws/client-s3\";\nimport { layer as fileSystemLayer } from \"@effect/platform-node/NodeFileSystem\";\nimport { layer as pathLayer } from \"@effect/platform-node/NodePath\";\nimport type { Database } from \"@tursodatabase/database\";\nimport { Effect } from \"effect\";\n\nimport { connect as connectEffect } from \"./src/index\";\nimport type { ConnectionOptions, S3qliteDatabase } from \"./src/types\";\n\ntype PromiseMethods = {\n\tpull: () => Promise<void>;\n\tpush: () => Promise<void>;\n\tsync: () => Promise<void>;\n\tfork: (dbName: string) => Promise<void>;\n\tcheckpoint: () => Promise<void>;\n};\n\nexport type PromiseS3qliteDatabase = Database & PromiseMethods;\n\nexport type PromiseConnectionOptions = ConnectionOptions & {\n\ts3?: Parameters<typeof S3.layer>[0];\n};\n\nconst toPromiseDatabase = (database: S3qliteDatabase): PromiseS3qliteDatabase => {\n\tconst pull = database.pull.bind(database);\n\tconst push = database.push.bind(database);\n\tconst sync = database.sync.bind(database);\n\tconst fork = database.fork.bind(database);\n\tconst checkpoint = database.checkpoint.bind(database);\n\n\treturn Object.assign(database as Database, {\n\t\tpull: () => Effect.runPromise(pull()),\n\t\tpush: () => Effect.runPromise(push()),\n\t\tsync: () => Effect.runPromise(sync()),\n\t\tfork: (dbName: string) => Effect.runPromise(fork(dbName)),\n\t\tcheckpoint: () => Effect.runPromise(checkpoint()),\n\t}) as PromiseS3qliteDatabase;\n};\n\nexport const connect = async (\n\tdbName: string,\n\t{ s3, ...connectionOptions }: PromiseConnectionOptions,\n): Promise<PromiseS3qliteDatabase> =>\n\tEffect.runPromise(\n\t\tEffect.scoped(\n\t\t\tconnectEffect(dbName, connectionOptions).pipe(\n\t\t\t\tEffect.provide(fileSystemLayer),\n\t\t\t\tEffect.provide(pathLayer),\n\t\t\t\tEffect.provide(S3.layer(s3 ?? {})),\n\t\t\t\tEffect.map(toPromiseDatabase),\n\t\t\t),\n\t\t),\n\t);\n\nexport * from \"./src/index.js\";"]}
@@ -0,0 +1,34 @@
1
+ import { Effect, Option } from "effect";
2
+ import { v4 } from "uuid";
3
+ import { replayCDC } from "./cdc/apply.js";
4
+ import { extractCDC } from "./cdc/extract.js";
5
+ import { deserializeCDC, serializeCDC } from "./cdc/protobuf.js";
6
+ import { LocalKV, batchKey } from "./storage.js";
7
+ export const applyBatch = (database, batch) => Effect.gen(function* () {
8
+ const localKv = yield* LocalKV;
9
+ const deserialized = deserializeCDC(yield* localKv.get(batchKey(batch.id)).pipe(Effect.flatMap(Option.match({
10
+ onNone: () => Effect.die(new Error(`Missing local batch ${batch.id}`)),
11
+ onSome: ({ value }) => Effect.succeed(value),
12
+ }))));
13
+ yield* replayCDC(database, deserialized).pipe(Effect.orDie);
14
+ });
15
+ export const extractBatch = (database, lastChangeId) => Effect.gen(function* () {
16
+ const localKV = yield* LocalKV;
17
+ const changes = yield* extractCDC(database, lastChangeId);
18
+ if (changes.length === 0) {
19
+ return Option.none();
20
+ }
21
+ const firstChange = changes[0];
22
+ const lastChange = changes[changes.length - 1];
23
+ if (!firstChange || !lastChange) {
24
+ return yield* Effect.die(new Error("Unexpected empty changes array"));
25
+ }
26
+ const id = v4();
27
+ const serialized = serializeCDC(changes);
28
+ yield* localKV.set(batchKey(id), serialized);
29
+ return Option.some({
30
+ batch: { id },
31
+ lastLocalChangeId: lastChange.changeId,
32
+ });
33
+ });
34
+ //# sourceMappingURL=batches.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"batches.js","sourceRoot":"","sources":["../../../src/batches.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AACxC,OAAO,EAAE,EAAE,EAAE,MAAM,MAAM,CAAC;AAE1B,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AACxC,OAAO,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAC3C,OAAO,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAC9D,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAC;AAG9C,MAAM,CAAC,MAAM,UAAU,GAAG,CAAC,QAAkB,EAAE,KAAY,EAAuC,EAAE,CACnG,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC;IACnB,MAAM,OAAO,GAAG,KAAK,CAAC,CAAC,OAAO,CAAC;IAC/B,MAAM,YAAY,GAAG,cAAc,CAClC,KAAK,CAAC,CAAC,OAAO,CAAC,GAAG,CAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,CAC1C,MAAM,CAAC,OAAO,CACb,MAAM,CAAC,KAAK,CAAC;QACZ,MAAM,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,KAAK,CAAC,uBAAuB,KAAK,CAAC,EAAE,EAAE,CAAC,CAAC;QACtE,MAAM,EAAE,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,CAAC,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC;KAC5C,CAAC,CACF,CACD,CACD,CAAC;IACF,KAAK,CAAC,CAAC,SAAS,CAAC,QAAQ,EAAE,YAAY,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;AAC7D,CAAC,CAAC,CAAC;AAOJ,MAAM,CAAC,MAAM,YAAY,GAAG,CAC3B,QAAkB,EAClB,YAAoB,EAC2C,EAAE,CACjE,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC;IACnB,MAAM,OAAO,GAAG,KAAK,CAAC,CAAC,OAAO,CAAC;IAC/B,MAAM,OAAO,GAAG,KAAK,CAAC,CAAC,UAAU,CAAC,QAAQ,EAAE,YAAY,CAAC,CAAC;IAE1D,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAC1B,OAAO,MAAM,CAAC,IAAI,EAAE,CAAC;IACtB,CAAC;IAED,MAAM,WAAW,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;IAC/B,MAAM,UAAU,GAAG,OAAO,CAAC,OAAO,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;IAE/C,IAAI,CAAC,WAAW,IAAI,CAAC,UAAU,EAAE,CAAC;QACjC,OAAO,KAAK,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,KAAK,CAAC,gCAAgC,CAAC,CAAC,CAAC;IACvE,CAAC;IAED,MAAM,EAAE,GAAG,EAAE,EAAE,CAAC;IAChB,MAAM,UAAU,GAAG,YAAY,CAAC,OAAO,CAAC,CAAC;IACzC,KAAK,CAAC,CAAC,OAAO,CAAC,GAAG,CAAC,QAAQ,CAAC,EAAE,CAAC,EAAE,UAAU,CAAC,CAAC;IAE7C,OAAO,MAAM,CAAC,IAAI,CAAiB;QAClC,KAAK,EAAE,EAAE,EAAE,EAAE;QACb,iBAAiB,EAAE,UAAU,CAAC,QAAQ;KACtC,CAAC,CAAC;AACJ,CAAC,CAAC,CAAC","sourcesContent":["import type { Database } from \"@tursodatabase/database\";\nimport { Effect, Option } from \"effect\";\nimport { v4 } from \"uuid\";\n\nimport { replayCDC } from \"./cdc/apply\";\nimport { extractCDC } from \"./cdc/extract\";\nimport { deserializeCDC, serializeCDC } from \"./cdc/protobuf\";\nimport { LocalKV, batchKey } from \"./storage\";\nimport type { Batch } from \"./types\";\n\nexport const applyBatch = (database: Database, batch: Batch): Effect.Effect<void, never, LocalKV> =>\n\tEffect.gen(function* () {\n\t\tconst localKv = yield* LocalKV;\n\t\tconst deserialized = deserializeCDC(\n\t\t\tyield* localKv.get(batchKey(batch.id)).pipe(\n\t\t\t\tEffect.flatMap(\n\t\t\t\t\tOption.match({\n\t\t\t\t\t\tonNone: () => Effect.die(new Error(`Missing local batch ${batch.id}`)),\n\t\t\t\t\t\tonSome: ({ value }) => Effect.succeed(value),\n\t\t\t\t\t}),\n\t\t\t\t),\n\t\t\t),\n\t\t);\n\t\tyield* replayCDC(database, deserialized).pipe(Effect.orDie);\n\t});\n\nexport type ExtractedBatch = {\n\tbatch: Batch;\n\tlastLocalChangeId: number;\n};\n\nexport const extractBatch = (\n\tdatabase: Database,\n\tlastChangeId: number,\n): Effect.Effect<Option.Option<ExtractedBatch>, never, LocalKV> =>\n\tEffect.gen(function* () {\n\t\tconst localKV = yield* LocalKV;\n\t\tconst changes = yield* extractCDC(database, lastChangeId);\n\n\t\tif (changes.length === 0) {\n\t\t\treturn Option.none();\n\t\t}\n\n\t\tconst firstChange = changes[0];\n\t\tconst lastChange = changes[changes.length - 1];\n\n\t\tif (!firstChange || !lastChange) {\n\t\t\treturn yield* Effect.die(new Error(\"Unexpected empty changes array\"));\n\t\t}\n\n\t\tconst id = v4();\n\t\tconst serialized = serializeCDC(changes);\n\t\tyield* localKV.set(batchKey(id), serialized);\n\n\t\treturn Option.some<ExtractedBatch>({\n\t\t\tbatch: { id },\n\t\t\tlastLocalChangeId: lastChange.changeId,\n\t\t});\n\t});"]}
@@ -0,0 +1,398 @@
1
+ import { Data, Effect } from "effect";
2
+ import { CDCChangeType } from "./types.js";
3
+ export class ReplayError extends Data.TaggedError("ReplayError") {
4
+ }
5
+ const textDecoder = new TextDecoder();
6
+ const readVarint = (bytes, offset) => {
7
+ let value = 0n;
8
+ for (let index = 0; index < 9; index += 1) {
9
+ const cursor = offset + index;
10
+ if (cursor >= bytes.length) {
11
+ throw new Error("Unexpected end of CDC record");
12
+ }
13
+ const byte = bytes[cursor];
14
+ if (index === 8) {
15
+ value = (value << 8n) | BigInt(byte);
16
+ const number = Number(value);
17
+ if (!Number.isSafeInteger(number)) {
18
+ throw new Error("CDC varint is too large");
19
+ }
20
+ return [number, cursor + 1];
21
+ }
22
+ value = (value << 7n) | BigInt(byte & 0x7f);
23
+ if ((byte & 0x80) === 0) {
24
+ const number = Number(value);
25
+ if (!Number.isSafeInteger(number)) {
26
+ throw new Error("CDC varint is too large");
27
+ }
28
+ return [number, cursor + 1];
29
+ }
30
+ }
31
+ throw new Error("Invalid CDC varint");
32
+ };
33
+ const readInteger = (bytes, offset, length) => {
34
+ let value = 0n;
35
+ for (let index = 0; index < length; index += 1) {
36
+ const cursor = offset + index;
37
+ if (cursor >= bytes.length) {
38
+ throw new Error("Unexpected end of CDC integer");
39
+ }
40
+ value = (value << 8n) | BigInt(bytes[cursor]);
41
+ }
42
+ const bits = BigInt(length * 8);
43
+ const signBit = 1n << (bits - 1n);
44
+ const signed = (value & signBit) === 0n ? value : value - (1n << bits);
45
+ const number = Number(signed);
46
+ if (!Number.isSafeInteger(number)) {
47
+ throw new Error("CDC integer is outside JS safe integer range");
48
+ }
49
+ return number;
50
+ };
51
+ const decodeValue = (bytes, offset, serialType) => {
52
+ switch (serialType) {
53
+ case 0:
54
+ // oxlint-disable-next-line local-rules/no-null-undefined-option -- SQLite record serial type 0 represents SQL NULL.
55
+ return [null, offset];
56
+ case 1:
57
+ return [readInteger(bytes, offset, 1), offset + 1];
58
+ case 2:
59
+ return [readInteger(bytes, offset, 2), offset + 2];
60
+ case 3:
61
+ return [readInteger(bytes, offset, 3), offset + 3];
62
+ case 4:
63
+ return [readInteger(bytes, offset, 4), offset + 4];
64
+ case 5:
65
+ return [readInteger(bytes, offset, 6), offset + 6];
66
+ case 6:
67
+ return [readInteger(bytes, offset, 8), offset + 8];
68
+ case 7:
69
+ if (offset + 8 > bytes.length) {
70
+ throw new Error("Unexpected end of CDC float");
71
+ }
72
+ return [
73
+ new DataView(bytes.buffer, bytes.byteOffset + offset, 8).getFloat64(0, false),
74
+ offset + 8,
75
+ ];
76
+ case 8:
77
+ return [0, offset];
78
+ case 9:
79
+ return [1, offset];
80
+ case 10:
81
+ case 11:
82
+ throw new Error(`Unsupported CDC serial type ${serialType}`);
83
+ default: {
84
+ const isBlob = serialType % 2 === 0;
85
+ const length = isBlob ? (serialType - 12) / 2 : (serialType - 13) / 2;
86
+ if (length < 0 || offset + length > bytes.length) {
87
+ throw new Error("Unexpected end of CDC payload");
88
+ }
89
+ const value = bytes.slice(offset, offset + length);
90
+ return [isBlob ? value : textDecoder.decode(value), offset + length];
91
+ }
92
+ }
93
+ };
94
+ const decodeRecord = (bytes) => Effect.try(() => {
95
+ const [headerSize, start] = readVarint(bytes, 0);
96
+ if (headerSize > bytes.length) {
97
+ throw new Error("CDC record header exceeds payload length");
98
+ }
99
+ const serialTypes = [];
100
+ let headerOffset = start;
101
+ while (headerOffset < headerSize) {
102
+ const [serialType, nextOffset] = readVarint(bytes, headerOffset);
103
+ serialTypes.push(serialType);
104
+ headerOffset = nextOffset;
105
+ }
106
+ const values = [];
107
+ let valueOffset = headerSize;
108
+ for (const serialType of serialTypes) {
109
+ const [value, nextOffset] = decodeValue(bytes, valueOffset, serialType);
110
+ values.push(value);
111
+ valueOffset = nextOffset;
112
+ }
113
+ return values;
114
+ });
115
+ const decodeSchemaRow = (bytes) => decodeRecord(bytes).pipe(Effect.flatMap((values) => values.length === 5 &&
116
+ typeof values[0] === "string" &&
117
+ typeof values[1] === "string" &&
118
+ typeof values[2] === "string" &&
119
+ typeof values[3] === "number" &&
120
+ // oxlint-disable-next-line local-rules/no-null-undefined-option -- sqlite_schema CDC can carry NULL SQL for implicit objects.
121
+ (typeof values[4] === "string" || values[4] === null)
122
+ ? Effect.succeed({
123
+ type: values[0],
124
+ name: values[1],
125
+ tblName: values[2],
126
+ rootpage: values[3],
127
+ sql: values[4],
128
+ })
129
+ : Effect.fail(new Error("Invalid sqlite_schema CDC record"))));
130
+ const parseUpdatedColumns = (columns, values) => {
131
+ if (values.length % 2 !== 0) {
132
+ return Effect.fail(new Error("Invalid CDC updates record"));
133
+ }
134
+ const columnCount = values.length / 2;
135
+ const recordColumns = columns.slice(0, columnCount);
136
+ const changedColumns = [];
137
+ const changedValues = [];
138
+ for (let index = 0; index < columnCount; index += 1) {
139
+ const flag = values[index];
140
+ if (flag !== 0 && flag !== 1) {
141
+ return Effect.fail(new Error(`Invalid CDC update flag at column ${index}`));
142
+ }
143
+ if (flag === 0) {
144
+ continue;
145
+ }
146
+ if (index >= recordColumns.length) {
147
+ return Effect.fail(new Error(`CDC updates reference missing column ${index}`));
148
+ }
149
+ changedColumns.push(recordColumns[index].name);
150
+ changedValues.push(values[columnCount + index]);
151
+ }
152
+ return Effect.succeed([changedColumns, changedValues]);
153
+ };
154
+ const getTableColumns = (database, tableName) => Effect.tryPromise(() => database.all(`PRAGMA table_info("${tableName.replaceAll('"', '""')}")`)).pipe(Effect.flatMap((columns) => columns.length > 0
155
+ ? Effect.succeed(columns)
156
+ : Effect.fail(new Error(`Table ${tableName} does not exist`))), Effect.tapError((error) => Effect.logError("cdc:get-table-columns").pipe(Effect.annotateLogs({ table: tableName, error: error.message }))));
157
+ const applySchemaInsert = (database, change) => decodeSchemaRow(change.after).pipe(Effect.flatMap((row) => {
158
+ // oxlint-disable-next-line local-rules/no-null-undefined-option -- sqlite_schema rows may have NULL SQL and should be skipped.
159
+ if (row.sql === null) {
160
+ return Effect.void;
161
+ }
162
+ const sql = row.sql.replace(/^(CREATE\s+(?:UNIQUE\s+)?(?:TABLE|INDEX|TRIGGER|VIEW|MATERIALIZED\s+VIEW)\s+)(?!IF\s+NOT\s+EXISTS\b)/i, "$1IF NOT EXISTS ");
163
+ return Effect.tryPromise({
164
+ try: () => database.run(sql),
165
+ catch: (error) => new Error(`Failed to apply schema insert: ${sql}: ${String(error)}`),
166
+ }).pipe(Effect.asVoid);
167
+ }));
168
+ const applySchemaDelete = (database, change) => decodeSchemaRow(change.before).pipe(Effect.flatMap((row) => {
169
+ // oxlint-disable-next-line local-rules/no-null-undefined-option -- sqlite_schema rows may have NULL SQL and should be skipped.
170
+ if (row.sql === null || row.name.startsWith("sqlite_")) {
171
+ return Effect.void;
172
+ }
173
+ const dropType = row.type === "table"
174
+ ? "TABLE"
175
+ : row.type === "index"
176
+ ? "INDEX"
177
+ : row.type === "view"
178
+ ? "VIEW"
179
+ : row.type === "trigger"
180
+ ? "TRIGGER"
181
+ : "";
182
+ if (dropType === "") {
183
+ return Effect.fail(new Error(`Unsupported sqlite_schema object type ${row.type}`));
184
+ }
185
+ const sql = `DROP ${dropType} IF EXISTS "${row.name.replaceAll('"', '""')}"`;
186
+ return Effect.tryPromise({
187
+ try: () => database.run(sql),
188
+ catch: (error) => new Error(`Failed to apply schema delete: ${sql}: ${String(error)}`),
189
+ }).pipe(Effect.asVoid);
190
+ }));
191
+ const applySchemaUpdate = (database, change) => decodeRecord(change.updates).pipe(Effect.flatMap((values) => {
192
+ if (values.length !== 10) {
193
+ return Effect.fail(new Error("Invalid sqlite_schema CDC update record"));
194
+ }
195
+ const ddl = values[9];
196
+ if (typeof ddl !== "string") {
197
+ return Effect.fail(new Error("Invalid sqlite_schema DDL update statement"));
198
+ }
199
+ const match = ddl.match(/^ALTER\s+TABLE\s+("(?:[^"]|"")+"|`[^`]+`|\[[^\]]+\]|\S+)\s+ADD\s+COLUMN\s+("(?:[^"]|"")+"|`[^`]+`|\[[^\]]+\]|\S+)/i);
200
+ if (!match) {
201
+ return Effect.tryPromise({
202
+ try: () => database.run(ddl),
203
+ catch: (error) => new Error(`Failed to apply schema update: ${ddl}: ${String(error)}`),
204
+ }).pipe(Effect.asVoid);
205
+ }
206
+ const tableName = match[1].startsWith('"') && match[1].endsWith('"')
207
+ ? match[1].slice(1, -1).replaceAll('""', '"')
208
+ : match[1].startsWith("`") && match[1].endsWith("`")
209
+ ? match[1].slice(1, -1)
210
+ : match[1].startsWith("[") && match[1].endsWith("]")
211
+ ? match[1].slice(1, -1)
212
+ : match[1];
213
+ const columnName = match[2].startsWith('"') && match[2].endsWith('"')
214
+ ? match[2].slice(1, -1).replaceAll('""', '"')
215
+ : match[2].startsWith("`") && match[2].endsWith("`")
216
+ ? match[2].slice(1, -1)
217
+ : match[2].startsWith("[") && match[2].endsWith("]")
218
+ ? match[2].slice(1, -1)
219
+ : match[2];
220
+ return getTableColumns(database, tableName).pipe(Effect.catchAll(() => Effect.succeed([])), Effect.flatMap((columns) => columns.some((column) => column.name === columnName)
221
+ ? Effect.void
222
+ : Effect.tryPromise({
223
+ try: () => database.run(ddl),
224
+ catch: (error) => new Error(`Failed to apply schema update: ${ddl}: ${String(error)}`),
225
+ }).pipe(Effect.asVoid)));
226
+ }));
227
+ const applyInsert = (database, change) => getTableColumns(database, change.tableName).pipe(Effect.flatMap((columns) => decodeRecord(change.after).pipe(Effect.flatMap((values) => {
228
+ const recordColumns = columns.slice(0, values.length);
229
+ if (recordColumns.length !== values.length) {
230
+ return Effect.fail(new Error(`CDC insert for ${change.tableName} has more columns than target table`));
231
+ }
232
+ const pkColumns = columns
233
+ .filter((column) => column.pk > 0)
234
+ .sort((left, right) => left.pk - right.pk);
235
+ const columnSql = recordColumns
236
+ .map((column) => `"${column.name.replaceAll('"', '""')}"`)
237
+ .join(", ");
238
+ const placeholders = recordColumns.map(() => "?").join(", ");
239
+ const conflictSql = pkColumns.length === 0
240
+ ? ""
241
+ : ` ON CONFLICT(${pkColumns.map((column) => `"${column.name.replaceAll('"', '""')}"`).join(", ")}) DO UPDATE SET ${recordColumns.map((column) => `"${column.name.replaceAll('"', '""')}" = excluded."${column.name.replaceAll('"', '""')}"`).join(", ")}`;
242
+ const sql = `INSERT INTO "${change.tableName.replaceAll('"', '""')}" (${columnSql}) VALUES (${placeholders})${conflictSql}`;
243
+ return Effect.tryPromise({
244
+ try: () => database.run(sql, ...values),
245
+ catch: (error) => new Error(`Failed to apply insert to ${change.tableName}: ${String(error)}`),
246
+ }).pipe(Effect.asVoid);
247
+ }))));
248
+ const applyDelete = (database, change) => getTableColumns(database, change.tableName).pipe(Effect.flatMap((columns) => decodeRecord(change.before).pipe(Effect.flatMap((values) => {
249
+ const pkColumns = columns
250
+ .filter((column) => column.pk > 0)
251
+ .sort((left, right) => left.pk - right.pk);
252
+ if (pkColumns.length === 0) {
253
+ // oxlint-disable-next-line local-rules/no-null-undefined-option -- rowid-free deletes need a missing CDC id check.
254
+ return change.id === null
255
+ ? Effect.fail(new Error(`Invalid CDC change ${change.changeId}: table ${change.tableName} has no primary key or rowid`))
256
+ : Effect.tryPromise({
257
+ try: () => database.run(`DELETE FROM "${change.tableName.replaceAll('"', '""')}" WHERE rowid = ?`, change.id),
258
+ catch: (error) => new Error(`Failed to apply delete by rowid on ${change.tableName}: ${String(error)}`),
259
+ }).pipe(Effect.asVoid);
260
+ }
261
+ const whereValues = pkColumns.map((column) => {
262
+ const index = columns.findIndex((item) => item.name === column.name);
263
+ if (index < 0 || index >= values.length) {
264
+ throw new Error(`Missing primary key column ${column.name} in CDC delete for ${change.tableName}`);
265
+ }
266
+ return values[index];
267
+ });
268
+ const sql = `DELETE FROM "${change.tableName.replaceAll('"', '""')}" WHERE ${pkColumns.map((column) => `"${column.name.replaceAll('"', '""')}" = ?`).join(" AND ")}`;
269
+ return Effect.tryPromise({
270
+ try: () => database.run(sql, ...whereValues),
271
+ catch: (error) => new Error(`Failed to apply delete on ${change.tableName}: ${String(error)}`),
272
+ }).pipe(Effect.asVoid);
273
+ }))));
274
+ const applyUpdateWithMask = (database, change) => getTableColumns(database, change.tableName).pipe(Effect.flatMap((columns) => Effect.all({
275
+ after: decodeRecord(change.after),
276
+ updates: decodeRecord(change.updates),
277
+ }).pipe(Effect.flatMap(({ after, updates }) => parseUpdatedColumns(columns, updates).pipe(Effect.flatMap(([changedColumns, changedValues]) => {
278
+ if (changedColumns.length === 0) {
279
+ return Effect.void;
280
+ }
281
+ const pkColumns = columns
282
+ .filter((column) => column.pk > 0)
283
+ .sort((left, right) => left.pk - right.pk);
284
+ if (pkColumns.length === 0) {
285
+ // oxlint-disable-next-line local-rules/no-null-undefined-option -- rowid-free updates need a missing CDC id check.
286
+ return change.id === null
287
+ ? Effect.fail(new Error(`Invalid CDC change ${change.changeId}: table ${change.tableName} has no primary key or rowid`))
288
+ : Effect.tryPromise({
289
+ try: () => database.run(`UPDATE "${change.tableName.replaceAll('"', '""')}" SET ${changedColumns.map((column) => `"${column.replaceAll('"', '""')}" = ?`).join(", ")} WHERE rowid = ?`, ...changedValues, change.id),
290
+ catch: (error) => new Error(`Failed to apply update by rowid on ${change.tableName}: ${String(error)}`),
291
+ }).pipe(Effect.asVoid);
292
+ }
293
+ const whereValues = pkColumns.map((column) => {
294
+ const index = columns.findIndex((item) => item.name === column.name);
295
+ if (index < 0 || index >= after.length) {
296
+ throw new Error(`Missing primary key column ${column.name} in CDC update for ${change.tableName}`);
297
+ }
298
+ return after[index];
299
+ });
300
+ const sql = `UPDATE "${change.tableName.replaceAll('"', '""')}" SET ${changedColumns.map((column) => `"${column.replaceAll('"', '""')}" = ?`).join(", ")} WHERE ${pkColumns.map((column) => `"${column.name.replaceAll('"', '""')}" = ?`).join(" AND ")}`;
301
+ return Effect.tryPromise({
302
+ try: () => database.run(sql, ...changedValues, ...whereValues),
303
+ catch: (error) => new Error(`Failed to apply update on ${change.tableName}: ${String(error)}`),
304
+ }).pipe(Effect.asVoid);
305
+ }))))));
306
+ const applyUpdateWithoutMask = (database, change) => applyDelete(database, {
307
+ ...change,
308
+ changeType: CDCChangeType.Delete,
309
+ // oxlint-disable-next-line local-rules/no-null-undefined-option -- update-as-delete replay must clear fields absent on delete CDC rows.
310
+ after: null,
311
+ // oxlint-disable-next-line local-rules/no-null-undefined-option -- update-as-delete replay must clear fields absent on delete CDC rows.
312
+ updates: null,
313
+ }).pipe(Effect.flatMap(() => applyInsert(database, {
314
+ ...change,
315
+ changeType: CDCChangeType.Insert,
316
+ // oxlint-disable-next-line local-rules/no-null-undefined-option -- update-as-insert replay must clear fields absent on insert CDC rows.
317
+ before: null,
318
+ // oxlint-disable-next-line local-rules/no-null-undefined-option -- update-as-insert replay must clear fields absent on insert CDC rows.
319
+ updates: null,
320
+ })));
321
+ export const applyCDC = (database, change) => {
322
+ switch (change.changeType) {
323
+ case CDCChangeType.Insert:
324
+ return change.tableName === "sqlite_schema"
325
+ ? applySchemaInsert(database, change)
326
+ : applyInsert(database, change);
327
+ case CDCChangeType.Delete:
328
+ return change.tableName === "sqlite_schema"
329
+ ? applySchemaDelete(database, change)
330
+ : applyDelete(database, change);
331
+ case CDCChangeType.Update:
332
+ return change.tableName === "sqlite_schema"
333
+ ? applySchemaUpdate(database, change)
334
+ : // oxlint-disable-next-line local-rules/no-null-undefined-option -- updates without a mask are represented by NULL in the CDC stream.
335
+ change.updates === null
336
+ ? applyUpdateWithoutMask(database, change)
337
+ : applyUpdateWithMask(database, change);
338
+ }
339
+ };
340
+ export const replayCDC = (database, changes) => {
341
+ if (changes.length === 0) {
342
+ return Effect.void;
343
+ }
344
+ const replay = Effect.gen(function* () {
345
+ yield* Effect.tryPromise({
346
+ try: () => database.exec("BEGIN IMMEDIATE"),
347
+ catch: (error) => new Error(`Failed to begin CDC transaction: ${String(error)}`),
348
+ }).pipe(Effect.asVoid);
349
+ const replayResult = yield* Effect.either(Effect.gen(function* () {
350
+ for (const change of changes) {
351
+ yield* applyCDC(database, change).pipe(Effect.annotateLogs({
352
+ changeId: String(change.changeId),
353
+ changeType: String(change.changeType),
354
+ tableName: change.tableName,
355
+ }), Effect.tapError((error) => Effect.logError("cdc:error").pipe(Effect.annotateLogs({
356
+ changeId: String(change.changeId),
357
+ changeType: String(change.changeType),
358
+ tableName: change.tableName,
359
+ error: error.message,
360
+ }))));
361
+ }
362
+ }).pipe(Effect.zipRight(Effect.tryPromise({
363
+ try: () => database.exec("COMMIT"),
364
+ catch: (error) => new Error(`Failed to commit CDC transaction: ${String(error)}`),
365
+ }).pipe(Effect.asVoid))));
366
+ if (replayResult._tag === "Right") {
367
+ return;
368
+ }
369
+ const error = replayResult.left;
370
+ const rollbackResult = yield* Effect.either(Effect.tryPromise({
371
+ try: () => database.exec("ROLLBACK"),
372
+ catch: (rollbackError) => new Error(`Failed to rollback CDC transaction: ${String(rollbackError)}`),
373
+ }).pipe(Effect.asVoid));
374
+ if (rollbackResult._tag === "Left") {
375
+ return yield* Effect.fail(new ReplayError({
376
+ message: `Failed to replay CDC: ${error instanceof Error ? error.message : String(error)}. Rollback failed: ${rollbackResult.left.message}`,
377
+ cause: error,
378
+ }));
379
+ }
380
+ return yield* Effect.fail(new ReplayError({
381
+ message: `Failed to replay CDC: ${error instanceof Error ? error.message : String(error)}`,
382
+ cause: error,
383
+ }));
384
+ });
385
+ return Effect.acquireUseRelease(Effect.tryPromise({
386
+ try: () => database.exec("PRAGMA capture_data_changes_conn('off')"),
387
+ catch: (error) => new Error(`Failed to disable CDC: ${String(error)}`),
388
+ }).pipe(Effect.asVoid), () => replay, () => Effect.tryPromise({
389
+ try: () => database.exec("PRAGMA capture_data_changes_conn('full')"),
390
+ catch: (error) => new Error(`Failed to enable CDC: ${String(error)}`),
391
+ }).pipe(Effect.asVoid, Effect.orDie)).pipe(Effect.mapError((error) => error instanceof ReplayError
392
+ ? error
393
+ : new ReplayError({
394
+ message: `Failed to replay CDC: ${error instanceof Error ? error.message : String(error)}`,
395
+ cause: error,
396
+ })));
397
+ };
398
+ //# sourceMappingURL=apply.js.map