@entifix/sql 0.11.0 → 0.12.1
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/dist/adapters/sql-repository-factory.d.ts +11 -0
- package/dist/adapters/sql-repository-factory.d.ts.map +1 -0
- package/dist/adapters/sql-repository-factory.js +17 -0
- package/dist/adapters/sql-repository-factory.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/transactions/idempotency.d.ts +15 -0
- package/dist/transactions/idempotency.d.ts.map +1 -0
- package/dist/transactions/idempotency.js +56 -0
- package/dist/transactions/idempotency.js.map +1 -0
- package/dist/transactions/index.d.ts +12 -0
- package/dist/transactions/index.d.ts.map +1 -0
- package/dist/transactions/index.js +12 -0
- package/dist/transactions/index.js.map +1 -0
- package/dist/transactions/outbox.d.ts +47 -0
- package/dist/transactions/outbox.d.ts.map +1 -0
- package/dist/transactions/outbox.js +99 -0
- package/dist/transactions/outbox.js.map +1 -0
- package/dist/transactions/relay.d.ts +22 -0
- package/dist/transactions/relay.d.ts.map +1 -0
- package/dist/transactions/relay.js +45 -0
- package/dist/transactions/relay.js.map +1 -0
- package/dist/transactions/schema.d.ts +17 -0
- package/dist/transactions/schema.d.ts.map +1 -0
- package/dist/transactions/schema.js +56 -0
- package/dist/transactions/schema.js.map +1 -0
- package/dist/transactions/unit-of-work.d.ts +17 -0
- package/dist/transactions/unit-of-work.d.ts.map +1 -0
- package/dist/transactions/unit-of-work.js +95 -0
- package/dist/transactions/unit-of-work.js.map +1 -0
- package/package.json +25 -5
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { EntityRepositoryFactoryTag } from '@entifix/business';
|
|
2
|
+
import { Layer } from 'effect';
|
|
3
|
+
import { SqlClient } from 'effect/sql';
|
|
4
|
+
/**
|
|
5
|
+
* The repository factory over the one SQL database the service connects to:
|
|
6
|
+
* every entity reads and writes its own table on the `SqlClient` the
|
|
7
|
+
* composition root provides. A route set asks the factory, never the client,
|
|
8
|
+
* so it names no engine.
|
|
9
|
+
*/
|
|
10
|
+
export declare const SqlRepositoryFactoryLayer: Layer.Layer<EntityRepositoryFactoryTag, never, SqlClient.SqlClient>;
|
|
11
|
+
//# sourceMappingURL=sql-repository-factory.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sql-repository-factory.d.ts","sourceRoot":"","sources":["../../src/adapters/sql-repository-factory.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,0BAA0B,EAAE,MAAM,mBAAmB,CAAC;AAC/D,OAAO,EAAU,KAAK,EAAE,MAAM,QAAQ,CAAC;AACvC,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAIvC;;;;;GAKG;AACH,eAAO,MAAM,yBAAyB,qEASrC,CAAC"}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { EntityRepositoryFactoryTag } from '@entifix/business';
|
|
2
|
+
import { Effect, Layer } from 'effect';
|
|
3
|
+
import { SqlClient } from 'effect/sql';
|
|
4
|
+
import { makeSqlRepository } from './make-sql-repository.js';
|
|
5
|
+
/**
|
|
6
|
+
* The repository factory over the one SQL database the service connects to:
|
|
7
|
+
* every entity reads and writes its own table on the `SqlClient` the
|
|
8
|
+
* composition root provides. A route set asks the factory, never the client,
|
|
9
|
+
* so it names no engine.
|
|
10
|
+
*/ export const SqlRepositoryFactoryLayer = Layer.effect(EntityRepositoryFactoryTag, Effect.gen(function*() {
|
|
11
|
+
const sql = yield* SqlClient.SqlClient;
|
|
12
|
+
return {
|
|
13
|
+
for: (entityConstructor)=>Effect.succeed(makeSqlRepository(sql, entityConstructor))
|
|
14
|
+
};
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
//# sourceMappingURL=sql-repository-factory.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/adapters/sql-repository-factory.ts"],"sourcesContent":["import { EntityRepositoryFactoryTag } from '@entifix/business';\nimport { Effect, Layer } from 'effect';\nimport { SqlClient } from 'effect/sql';\n\nimport { makeSqlRepository } from './make-sql-repository.js';\n\n/**\n * The repository factory over the one SQL database the service connects to:\n * every entity reads and writes its own table on the `SqlClient` the\n * composition root provides. A route set asks the factory, never the client,\n * so it names no engine.\n */\nexport const SqlRepositoryFactoryLayer = Layer.effect(\n EntityRepositoryFactoryTag,\n Effect.gen(function* () {\n const sql = yield* SqlClient.SqlClient;\n return {\n for: entityConstructor =>\n Effect.succeed(makeSqlRepository(sql, entityConstructor)),\n };\n }),\n);\n"],"names":["EntityRepositoryFactoryTag","Effect","Layer","SqlClient","makeSqlRepository","SqlRepositoryFactoryLayer","effect","gen","sql","for","entityConstructor","succeed"],"mappings":"AAAA,SAASA,0BAA0B,QAAQ,oBAAoB;AAC/D,SAASC,MAAM,EAAEC,KAAK,QAAQ,SAAS;AACvC,SAASC,SAAS,QAAQ,aAAa;AAEvC,SAASC,iBAAiB,QAAQ,2BAA2B;AAE7D;;;;;CAKC,GACD,OAAO,MAAMC,4BAA4BH,MAAMI,MAAM,CACnDN,4BACAC,OAAOM,GAAG,CAAC;IACT,MAAMC,MAAM,OAAOL,UAAUA,SAAS;IACtC,OAAO;QACLM,KAAKC,CAAAA,oBACHT,OAAOU,OAAO,CAACP,kBAAkBI,KAAKE;IAC1C;AACF,IACA"}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export * from './adapters/make-sql-repository.js';
|
|
2
2
|
export * from './adapters/sql-filter-translator.js';
|
|
3
|
+
export * from './adapters/sql-repository-factory.js';
|
|
4
|
+
export * from './errors/translate-sql-error.js';
|
|
3
5
|
export * from './sql-database/sql-health-probe.js';
|
|
4
6
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,mCAAmC,CAAC;AAClD,cAAc,qCAAqC,CAAC;AACpD,cAAc,oCAAoC,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,mCAAmC,CAAC;AAClD,cAAc,qCAAqC,CAAC;AACpD,cAAc,sCAAsC,CAAC;AACrD,cAAc,iCAAiC,CAAC;AAChD,cAAc,oCAAoC,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export * from './adapters/make-sql-repository.js';
|
|
2
2
|
export * from './adapters/sql-filter-translator.js';
|
|
3
|
+
export * from './adapters/sql-repository-factory.js';
|
|
4
|
+
export * from './errors/translate-sql-error.js';
|
|
3
5
|
export * from './sql-database/sql-health-probe.js';
|
|
4
6
|
|
|
5
7
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["export * from './adapters/make-sql-repository.js';\nexport * from './adapters/sql-filter-translator.js';\nexport * from './sql-database/sql-health-probe.js';\n"],"names":[],"mappings":"AAAA,cAAc,oCAAoC;AAClD,cAAc,sCAAsC;AACpD,cAAc,qCAAqC"}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["export * from './adapters/make-sql-repository.js';\nexport * from './adapters/sql-filter-translator.js';\nexport * from './adapters/sql-repository-factory.js';\nexport * from './errors/translate-sql-error.js';\nexport * from './sql-database/sql-health-probe.js';\n"],"names":[],"mappings":"AAAA,cAAc,oCAAoC;AAClD,cAAc,sCAAsC;AACpD,cAAc,uCAAuC;AACrD,cAAc,kCAAkC;AAChD,cAAc,qCAAqC"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { IdempotencyStore } from '@entifix/business';
|
|
2
|
+
import type { SqlClient } from 'effect/sql';
|
|
3
|
+
export interface SqlIdempotencyOptions {
|
|
4
|
+
/** How long a record lives, in milliseconds. */
|
|
5
|
+
readonly retentionMs: number;
|
|
6
|
+
/** The clock, in epoch milliseconds. */
|
|
7
|
+
readonly now?: () => number;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* The idempotency store over SQL. A claim is one statement: it inserts the key,
|
|
11
|
+
* or takes over a record that has expired, and only one of two concurrent
|
|
12
|
+
* callers sees its row come back.
|
|
13
|
+
*/
|
|
14
|
+
export declare const makeSqlIdempotencyStore: (sql: SqlClient.SqlClient, { retentionMs, now }: SqlIdempotencyOptions) => IdempotencyStore;
|
|
15
|
+
//# sourceMappingURL=idempotency.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"idempotency.d.ts","sourceRoot":"","sources":["../../src/transactions/idempotency.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAGV,gBAAgB,EAEjB,MAAM,mBAAmB,CAAC;AAE3B,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AA2B5C,MAAM,WAAW,qBAAqB;IACpC,gDAAgD;IAChD,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,wCAAwC;IACxC,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;CAC7B;AAID;;;;GAIG;AACH,eAAO,MAAM,uBAAuB,GAClC,KAAK,SAAS,CAAC,SAAS,EACxB,sBAAiC,qBAAqB,KACrD,gBA2CD,CAAC"}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
import { Effect } from 'effect';
|
|
3
|
+
import { translateSqlError } from '../errors/translate-sql-error.js';
|
|
4
|
+
const recordOf = (row)=>_extends({
|
|
5
|
+
dedupKey: row.dedup_key,
|
|
6
|
+
fingerprint: row.fingerprint,
|
|
7
|
+
state: row.state
|
|
8
|
+
}, row.response == null ? {} : {
|
|
9
|
+
response: typeof row.response === 'string' ? JSON.parse(row.response) : row.response
|
|
10
|
+
}, {
|
|
11
|
+
expiresAt: new Date(row.expires_at).toISOString()
|
|
12
|
+
});
|
|
13
|
+
const fault = (error)=>translateSqlError(error);
|
|
14
|
+
/**
|
|
15
|
+
* The idempotency store over SQL. A claim is one statement: it inserts the key,
|
|
16
|
+
* or takes over a record that has expired, and only one of two concurrent
|
|
17
|
+
* callers sees its row come back.
|
|
18
|
+
*/ export const makeSqlIdempotencyStore = (sql, { retentionMs, now = Date.now })=>({
|
|
19
|
+
claim: (dedupKey, fingerprint)=>Effect.gen(function*() {
|
|
20
|
+
const at = new Date(now());
|
|
21
|
+
const expiresAt = new Date(now() + retentionMs);
|
|
22
|
+
const taken = yield* sql`
|
|
23
|
+
INSERT INTO entifix_idempotency (dedup_key, fingerprint, state, expires_at)
|
|
24
|
+
VALUES (${dedupKey}, ${fingerprint}, 'inFlight', ${expiresAt})
|
|
25
|
+
ON CONFLICT (dedup_key) DO UPDATE
|
|
26
|
+
SET fingerprint = EXCLUDED.fingerprint, state = 'inFlight',
|
|
27
|
+
response = NULL, expires_at = EXCLUDED.expires_at
|
|
28
|
+
WHERE entifix_idempotency.expires_at <= ${at}
|
|
29
|
+
RETURNING dedup_key`;
|
|
30
|
+
if (taken.length > 0) return {
|
|
31
|
+
_tag: 'claimed'
|
|
32
|
+
};
|
|
33
|
+
const [existing] = yield* sql`
|
|
34
|
+
SELECT * FROM entifix_idempotency WHERE dedup_key = ${dedupKey}`;
|
|
35
|
+
// Released between the two statements: one more claim takes it.
|
|
36
|
+
if (existing === undefined) {
|
|
37
|
+
yield* sql`
|
|
38
|
+
INSERT INTO entifix_idempotency (dedup_key, fingerprint, state, expires_at)
|
|
39
|
+
VALUES (${dedupKey}, ${fingerprint}, 'inFlight', ${expiresAt})`;
|
|
40
|
+
return {
|
|
41
|
+
_tag: 'claimed'
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
_tag: 'existing',
|
|
46
|
+
record: recordOf(existing)
|
|
47
|
+
};
|
|
48
|
+
}).pipe(Effect.mapError(fault)),
|
|
49
|
+
complete: (dedupKey, response)=>sql`
|
|
50
|
+
UPDATE entifix_idempotency
|
|
51
|
+
SET state = 'completed', response = ${JSON.stringify(response)}::jsonb
|
|
52
|
+
WHERE dedup_key = ${dedupKey}`.pipe(Effect.asVoid, Effect.mapError(fault)),
|
|
53
|
+
release: (dedupKey)=>sql`DELETE FROM entifix_idempotency WHERE dedup_key = ${dedupKey}`.pipe(Effect.asVoid, Effect.mapError(fault))
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
//# sourceMappingURL=idempotency.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/transactions/idempotency.ts"],"sourcesContent":["import type {\n IdempotencyClaim,\n IdempotencyRecord,\n IdempotencyStore,\n StoredResponse,\n} from '@entifix/business';\nimport { Effect } from 'effect';\nimport type { SqlClient } from 'effect/sql';\n\nimport { translateSqlError } from '../errors/translate-sql-error.js';\n\ninterface IdempotencyRow {\n readonly dedup_key: string;\n readonly fingerprint: string;\n readonly state: 'inFlight' | 'completed';\n readonly response: StoredResponse | string | null;\n readonly expires_at: Date | string;\n}\n\nconst recordOf = (row: IdempotencyRow): IdempotencyRecord => ({\n dedupKey: row.dedup_key,\n fingerprint: row.fingerprint,\n state: row.state,\n ...(row.response == null\n ? {}\n : {\n response:\n typeof row.response === 'string'\n ? (JSON.parse(row.response) as StoredResponse)\n : row.response,\n }),\n expiresAt: new Date(row.expires_at).toISOString(),\n});\n\nexport interface SqlIdempotencyOptions {\n /** How long a record lives, in milliseconds. */\n readonly retentionMs: number;\n /** The clock, in epoch milliseconds. */\n readonly now?: () => number;\n}\n\nconst fault = (error: unknown) => translateSqlError(error);\n\n/**\n * The idempotency store over SQL. A claim is one statement: it inserts the key,\n * or takes over a record that has expired, and only one of two concurrent\n * callers sees its row come back.\n */\nexport const makeSqlIdempotencyStore = (\n sql: SqlClient.SqlClient,\n { retentionMs, now = Date.now }: SqlIdempotencyOptions,\n): IdempotencyStore => ({\n claim: (dedupKey, fingerprint) =>\n Effect.gen(function* () {\n const at = new Date(now());\n const expiresAt = new Date(now() + retentionMs);\n const taken = yield* sql<{ readonly dedup_key: string }>`\n INSERT INTO entifix_idempotency (dedup_key, fingerprint, state, expires_at)\n VALUES (${dedupKey}, ${fingerprint}, 'inFlight', ${expiresAt})\n ON CONFLICT (dedup_key) DO UPDATE\n SET fingerprint = EXCLUDED.fingerprint, state = 'inFlight',\n response = NULL, expires_at = EXCLUDED.expires_at\n WHERE entifix_idempotency.expires_at <= ${at}\n RETURNING dedup_key`;\n if (taken.length > 0) return { _tag: 'claimed' } as IdempotencyClaim;\n const [existing] = yield* sql<IdempotencyRow>`\n SELECT * FROM entifix_idempotency WHERE dedup_key = ${dedupKey}`;\n // Released between the two statements: one more claim takes it.\n if (existing === undefined) {\n yield* sql`\n INSERT INTO entifix_idempotency (dedup_key, fingerprint, state, expires_at)\n VALUES (${dedupKey}, ${fingerprint}, 'inFlight', ${expiresAt})`;\n return { _tag: 'claimed' } as IdempotencyClaim;\n }\n return {\n _tag: 'existing',\n record: recordOf(existing),\n } as IdempotencyClaim;\n }).pipe(Effect.mapError(fault)),\n\n complete: (dedupKey, response) =>\n sql`\n UPDATE entifix_idempotency\n SET state = 'completed', response = ${JSON.stringify(response)}::jsonb\n WHERE dedup_key = ${dedupKey}`.pipe(\n Effect.asVoid,\n Effect.mapError(fault),\n ),\n\n release: dedupKey =>\n sql`DELETE FROM entifix_idempotency WHERE dedup_key = ${dedupKey}`.pipe(\n Effect.asVoid,\n Effect.mapError(fault),\n ),\n});\n"],"names":["Effect","translateSqlError","recordOf","row","dedupKey","dedup_key","fingerprint","state","response","JSON","parse","expiresAt","Date","expires_at","toISOString","fault","error","makeSqlIdempotencyStore","sql","retentionMs","now","claim","gen","at","taken","length","_tag","existing","undefined","record","pipe","mapError","complete","stringify","asVoid","release"],"mappings":";AAMA,SAASA,MAAM,QAAQ,SAAS;AAGhC,SAASC,iBAAiB,QAAQ,mCAAmC;AAUrE,MAAMC,WAAW,CAACC,MAA4C;QAC5DC,UAAUD,IAAIE,SAAS;QACvBC,aAAaH,IAAIG,WAAW;QAC5BC,OAAOJ,IAAII,KAAK;OACZJ,IAAIK,QAAQ,IAAI,OAChB,CAAC,IACD;QACEA,UACE,OAAOL,IAAIK,QAAQ,KAAK,WACnBC,KAAKC,KAAK,CAACP,IAAIK,QAAQ,IACxBL,IAAIK,QAAQ;IACpB;QACJG,WAAW,IAAIC,KAAKT,IAAIU,UAAU,EAAEC,WAAW;;AAUjD,MAAMC,QAAQ,CAACC,QAAmBf,kBAAkBe;AAEpD;;;;CAIC,GACD,OAAO,MAAMC,0BAA0B,CACrCC,KACA,EAAEC,WAAW,EAAEC,MAAMR,KAAKQ,GAAG,EAAyB,GAChC,CAAA;QACtBC,OAAO,CAACjB,UAAUE,cAChBN,OAAOsB,GAAG,CAAC;gBACT,MAAMC,KAAK,IAAIX,KAAKQ;gBACpB,MAAMT,YAAY,IAAIC,KAAKQ,QAAQD;gBACnC,MAAMK,QAAQ,OAAON,GAAmC,CAAC;;gBAE/C,EAAEd,SAAS,EAAE,EAAEE,YAAY,cAAc,EAAEK,UAAU;;;;kDAInB,EAAEY,GAAG;2BAC5B,CAAC;gBACtB,IAAIC,MAAMC,MAAM,GAAG,GAAG,OAAO;oBAAEC,MAAM;gBAAU;gBAC/C,MAAM,CAACC,SAAS,GAAG,OAAOT,GAAmB,CAAC;4DACQ,EAAEd,SAAS,CAAC;gBAClE,gEAAgE;gBAChE,IAAIuB,aAAaC,WAAW;oBAC1B,OAAOV,GAAG,CAAC;;kBAED,EAAEd,SAAS,EAAE,EAAEE,YAAY,cAAc,EAAEK,UAAU,CAAC,CAAC;oBACjE,OAAO;wBAAEe,MAAM;oBAAU;gBAC3B;gBACA,OAAO;oBACLA,MAAM;oBACNG,QAAQ3B,SAASyB;gBACnB;YACF,GAAGG,IAAI,CAAC9B,OAAO+B,QAAQ,CAAChB;QAE1BiB,UAAU,CAAC5B,UAAUI,WACnBU,GAAG,CAAC;;0CAEkC,EAAET,KAAKwB,SAAS,CAACzB,UAAU;wBAC7C,EAAEJ,SAAS,CAAC,CAAC0B,IAAI,CACnC9B,OAAOkC,MAAM,EACblC,OAAO+B,QAAQ,CAAChB;QAGpBoB,SAAS/B,CAAAA,WACPc,GAAG,CAAC,kDAAkD,EAAEd,SAAS,CAAC,CAAC0B,IAAI,CACrE9B,OAAOkC,MAAM,EACblC,OAAO+B,QAAQ,CAAChB;IAEtB,CAAA,EAAG"}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The half of this package that needs `@entifix/transactions`: the unit of
|
|
3
|
+
* work, the outbox and its relay, the inboxes and the idempotency store over
|
|
4
|
+
* SQL. Behind a subpath, with that package an optional peer, so entity CRUD
|
|
5
|
+
* over SQL does not arrive with the outbox attached.
|
|
6
|
+
*/
|
|
7
|
+
export * from './idempotency.js';
|
|
8
|
+
export * from './outbox.js';
|
|
9
|
+
export * from './relay.js';
|
|
10
|
+
export * from './schema.js';
|
|
11
|
+
export * from './unit-of-work.js';
|
|
12
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/transactions/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,cAAc,kBAAkB,CAAC;AACjC,cAAc,aAAa,CAAC;AAC5B,cAAc,YAAY,CAAC;AAC3B,cAAc,aAAa,CAAC;AAC5B,cAAc,mBAAmB,CAAC"}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The half of this package that needs `@entifix/transactions`: the unit of
|
|
3
|
+
* work, the outbox and its relay, the inboxes and the idempotency store over
|
|
4
|
+
* SQL. Behind a subpath, with that package an optional peer, so entity CRUD
|
|
5
|
+
* over SQL does not arrive with the outbox attached.
|
|
6
|
+
*/ export * from './idempotency.js';
|
|
7
|
+
export * from './outbox.js';
|
|
8
|
+
export * from './relay.js';
|
|
9
|
+
export * from './schema.js';
|
|
10
|
+
export * from './unit-of-work.js';
|
|
11
|
+
|
|
12
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/transactions/index.ts"],"sourcesContent":["/**\n * The half of this package that needs `@entifix/transactions`: the unit of\n * work, the outbox and its relay, the inboxes and the idempotency store over\n * SQL. Behind a subpath, with that package an optional peer, so entity CRUD\n * over SQL does not arrive with the outbox attached.\n */\nexport * from './idempotency.js';\nexport * from './outbox.js';\nexport * from './relay.js';\nexport * from './schema.js';\nexport * from './unit-of-work.js';\n"],"names":[],"mappings":"AAAA;;;;;CAKC,GACD,cAAc,mBAAmB;AACjC,cAAc,cAAc;AAC5B,cAAc,aAAa;AAC3B,cAAc,cAAc;AAC5B,cAAc,oBAAoB"}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Outbox, OutboxEntry, OutboxEvent } from '@entifix/transactions';
|
|
2
|
+
import type { SqlClient } from 'effect/sql';
|
|
3
|
+
/** A row of `entifix_outbox`, as the driver hands it back. */
|
|
4
|
+
export type OutboxRow = {
|
|
5
|
+
readonly event_id: string;
|
|
6
|
+
readonly aggregate_type: string;
|
|
7
|
+
readonly aggregate_id: string;
|
|
8
|
+
readonly version: number;
|
|
9
|
+
readonly event: OutboxEvent | string;
|
|
10
|
+
readonly attempts: number;
|
|
11
|
+
readonly last_error: string | null;
|
|
12
|
+
readonly quarantined: boolean;
|
|
13
|
+
readonly sent: boolean;
|
|
14
|
+
readonly created_at: Date | string;
|
|
15
|
+
};
|
|
16
|
+
/** A row as the outbox port's entry. `jsonb` may arrive parsed or as text. */
|
|
17
|
+
export declare const entryOf: (row: OutboxRow) => OutboxEntry;
|
|
18
|
+
/** The values one event is inserted with. */
|
|
19
|
+
export declare const outboxRow: (event: OutboxEvent) => {
|
|
20
|
+
event_id: string;
|
|
21
|
+
aggregate_type: string;
|
|
22
|
+
aggregate_id: string;
|
|
23
|
+
version: number;
|
|
24
|
+
event: string;
|
|
25
|
+
created_at: string;
|
|
26
|
+
};
|
|
27
|
+
export interface SqlOutboxOptions {
|
|
28
|
+
/**
|
|
29
|
+
* How long a relay holds the entries it took before another may take them —
|
|
30
|
+
* long enough to publish a batch, short enough that a relay that died does
|
|
31
|
+
* not hold its aggregates past the next sweep. Thirty seconds by default.
|
|
32
|
+
*/
|
|
33
|
+
readonly leaseSeconds?: number;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The outbox over Postgres
|
|
37
|
+
* ([ADR 0018](../../../../../docs/adr/0018-one-outbox-ordered-per-aggregate.md)).
|
|
38
|
+
*
|
|
39
|
+
* `pending` **claims** what it returns: it leases the oldest entries of the
|
|
40
|
+
* aggregates no other relay holds, with `FOR UPDATE SKIP LOCKED` and an
|
|
41
|
+
* advisory lock per aggregate taken in the same statement, so several runners
|
|
42
|
+
* drain one table without publishing an entry twice or two versions of one
|
|
43
|
+
* aggregate out of order. A failure gives the whole aggregate back at once, so
|
|
44
|
+
* the next pass retries it rather than waiting out the lease.
|
|
45
|
+
*/
|
|
46
|
+
export declare const makeSqlOutbox: (sql: SqlClient.SqlClient, options?: SqlOutboxOptions) => Outbox;
|
|
47
|
+
//# sourceMappingURL=outbox.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"outbox.d.ts","sourceRoot":"","sources":["../../src/transactions/outbox.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AAE9E,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAI5C,8DAA8D;AAG9D,MAAM,MAAM,SAAS,GAAG;IACtB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;IAChC,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,KAAK,EAAE,WAAW,GAAG,MAAM,CAAC;IACrC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IACnC,QAAQ,CAAC,WAAW,EAAE,OAAO,CAAC;IAC9B,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;IACvB,QAAQ,CAAC,UAAU,EAAE,IAAI,GAAG,MAAM,CAAC;CACpC,CAAC;AAKF,8EAA8E;AAC9E,eAAO,MAAM,OAAO,GAAI,KAAK,SAAS,KAAG,WAavC,CAAC;AAEH,6CAA6C;AAC7C,eAAO,MAAM,SAAS,GAAI,OAAO,WAAW;;;;;;;CAO1C,CAAC;AAEH,MAAM,WAAW,gBAAgB;IAC/B;;;;OAIG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;CAChC;AAID;;;;;;;;;;GAUG;AACH,eAAO,MAAM,aAAa,GACxB,KAAK,SAAS,CAAC,SAAS,EACxB,UAAS,gBAAqB,KAC7B,MA4FF,CAAC"}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
import { Effect } from 'effect';
|
|
3
|
+
import { translateSqlError } from '../errors/translate-sql-error.js';
|
|
4
|
+
const iso = (value)=>value instanceof Date ? value.toISOString() : new Date(value).toISOString();
|
|
5
|
+
/** A row as the outbox port's entry. `jsonb` may arrive parsed or as text. */ export const entryOf = (row)=>_extends({
|
|
6
|
+
eventId: row.event_id,
|
|
7
|
+
aggregate: {
|
|
8
|
+
type: row.aggregate_type,
|
|
9
|
+
id: row.aggregate_id
|
|
10
|
+
},
|
|
11
|
+
version: row.version,
|
|
12
|
+
event: typeof row.event === 'string' ? JSON.parse(row.event) : row.event,
|
|
13
|
+
sent: row.sent,
|
|
14
|
+
attempts: row.attempts
|
|
15
|
+
}, row.last_error === null ? {} : {
|
|
16
|
+
lastError: row.last_error
|
|
17
|
+
}, {
|
|
18
|
+
quarantined: row.quarantined,
|
|
19
|
+
createdAt: iso(row.created_at)
|
|
20
|
+
});
|
|
21
|
+
/** The values one event is inserted with. */ export const outboxRow = (event)=>({
|
|
22
|
+
event_id: event.id,
|
|
23
|
+
aggregate_type: event.aggregate.type,
|
|
24
|
+
aggregate_id: event.aggregate.id,
|
|
25
|
+
version: event.version,
|
|
26
|
+
event: JSON.stringify(event),
|
|
27
|
+
created_at: event.at
|
|
28
|
+
});
|
|
29
|
+
const fault = (error)=>translateSqlError(error);
|
|
30
|
+
/**
|
|
31
|
+
* The outbox over Postgres
|
|
32
|
+
* ([ADR 0018](../../../../../docs/adr/0018-one-outbox-ordered-per-aggregate.md)).
|
|
33
|
+
*
|
|
34
|
+
* `pending` **claims** what it returns: it leases the oldest entries of the
|
|
35
|
+
* aggregates no other relay holds, with `FOR UPDATE SKIP LOCKED` and an
|
|
36
|
+
* advisory lock per aggregate taken in the same statement, so several runners
|
|
37
|
+
* drain one table without publishing an entry twice or two versions of one
|
|
38
|
+
* aggregate out of order. A failure gives the whole aggregate back at once, so
|
|
39
|
+
* the next pass retries it rather than waiting out the lease.
|
|
40
|
+
*/ export const makeSqlOutbox = (sql, options = {})=>{
|
|
41
|
+
var _options_leaseSeconds;
|
|
42
|
+
const lease = `${String((_options_leaseSeconds = options.leaseSeconds) != null ? _options_leaseSeconds : 30)} seconds`;
|
|
43
|
+
return {
|
|
44
|
+
enqueue: (event)=>sql`
|
|
45
|
+
INSERT INTO entifix_outbox ${sql.insert(outboxRow(event))}
|
|
46
|
+
ON CONFLICT (event_id) DO NOTHING
|
|
47
|
+
RETURNING event_id`.pipe(Effect.map((rows)=>rows.length === 0 ? 'duplicate' : 'enqueued'), Effect.mapError(fault)),
|
|
48
|
+
pending: (limit)=>sql`
|
|
49
|
+
WITH picked AS (
|
|
50
|
+
SELECT o.event_id FROM entifix_outbox o
|
|
51
|
+
WHERE NOT o.sent AND NOT o.quarantined
|
|
52
|
+
AND (o.lease_until IS NULL OR o.lease_until < now())
|
|
53
|
+
AND NOT EXISTS (
|
|
54
|
+
SELECT 1 FROM entifix_outbox h
|
|
55
|
+
WHERE h.aggregate_type = o.aggregate_type
|
|
56
|
+
AND h.aggregate_id = o.aggregate_id
|
|
57
|
+
AND NOT h.sent AND NOT h.quarantined
|
|
58
|
+
AND h.lease_until >= now()
|
|
59
|
+
)
|
|
60
|
+
AND pg_try_advisory_xact_lock(
|
|
61
|
+
hashtext(o.aggregate_type || ':' || o.aggregate_id)
|
|
62
|
+
)
|
|
63
|
+
ORDER BY o.created_at, o.version
|
|
64
|
+
LIMIT ${limit}
|
|
65
|
+
FOR UPDATE SKIP LOCKED
|
|
66
|
+
)
|
|
67
|
+
UPDATE entifix_outbox AS t
|
|
68
|
+
SET lease_until = now() + ${lease}::interval
|
|
69
|
+
FROM picked WHERE t.event_id = picked.event_id
|
|
70
|
+
RETURNING t.*`.pipe(Effect.map((rows)=>rows.map(entryOf).sort((a, b)=>a.createdAt.localeCompare(b.createdAt) || a.version - b.version)), Effect.mapError(fault)),
|
|
71
|
+
markSent: (entry)=>sql`
|
|
72
|
+
UPDATE entifix_outbox SET sent = true, lease_until = NULL
|
|
73
|
+
WHERE event_id = ${entry.eventId}`.pipe(Effect.asVoid, Effect.mapError(fault)),
|
|
74
|
+
recordFailure: (entry, error, quarantine)=>sql`
|
|
75
|
+
UPDATE entifix_outbox
|
|
76
|
+
SET attempts = attempts + 1, last_error = ${error},
|
|
77
|
+
quarantined = ${quarantine}
|
|
78
|
+
WHERE event_id = ${entry.eventId}`.pipe(Effect.andThen(sql`
|
|
79
|
+
UPDATE entifix_outbox SET lease_until = NULL
|
|
80
|
+
WHERE aggregate_type = ${entry.aggregate.type}
|
|
81
|
+
AND aggregate_id = ${entry.aggregate.id} AND NOT sent`), Effect.asVoid, Effect.mapError(fault)),
|
|
82
|
+
stats: ()=>sql`
|
|
83
|
+
SELECT
|
|
84
|
+
count(*) FILTER (WHERE NOT sent AND NOT quarantined) AS pending,
|
|
85
|
+
count(*) FILTER (WHERE quarantined) AS quarantined,
|
|
86
|
+
min(created_at) FILTER (WHERE NOT sent AND NOT quarantined) AS oldest
|
|
87
|
+
FROM entifix_outbox`.pipe(Effect.map(([row])=>{
|
|
88
|
+
var _ref, _ref1;
|
|
89
|
+
return _extends({
|
|
90
|
+
pending: Number((_ref = row == null ? void 0 : row.pending) != null ? _ref : 0),
|
|
91
|
+
quarantined: Number((_ref1 = row == null ? void 0 : row.quarantined) != null ? _ref1 : 0)
|
|
92
|
+
}, (row == null ? void 0 : row.oldest) == null ? {} : {
|
|
93
|
+
oldestPendingAt: iso(row.oldest)
|
|
94
|
+
});
|
|
95
|
+
}), Effect.mapError(fault))
|
|
96
|
+
};
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
//# sourceMappingURL=outbox.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/transactions/outbox.ts"],"sourcesContent":["import type { Outbox, OutboxEntry, OutboxEvent } from '@entifix/transactions';\nimport { Effect } from 'effect';\nimport type { SqlClient } from 'effect/sql';\n\nimport { translateSqlError } from '../errors/translate-sql-error.js';\n\n/** A row of `entifix_outbox`, as the driver hands it back. */\n// A type alias, not an interface: a row must be assignable to the record a\n// driver hands back, and an interface has no index signature.\nexport type OutboxRow = {\n readonly event_id: string;\n readonly aggregate_type: string;\n readonly aggregate_id: string;\n readonly version: number;\n readonly event: OutboxEvent | string;\n readonly attempts: number;\n readonly last_error: string | null;\n readonly quarantined: boolean;\n readonly sent: boolean;\n readonly created_at: Date | string;\n};\n\nconst iso = (value: Date | string): string =>\n value instanceof Date ? value.toISOString() : new Date(value).toISOString();\n\n/** A row as the outbox port's entry. `jsonb` may arrive parsed or as text. */\nexport const entryOf = (row: OutboxRow): OutboxEntry => ({\n eventId: row.event_id,\n aggregate: { type: row.aggregate_type, id: row.aggregate_id },\n version: row.version,\n event:\n typeof row.event === 'string'\n ? (JSON.parse(row.event) as OutboxEvent)\n : row.event,\n sent: row.sent,\n attempts: row.attempts,\n ...(row.last_error === null ? {} : { lastError: row.last_error }),\n quarantined: row.quarantined,\n createdAt: iso(row.created_at),\n});\n\n/** The values one event is inserted with. */\nexport const outboxRow = (event: OutboxEvent) => ({\n event_id: event.id,\n aggregate_type: event.aggregate.type,\n aggregate_id: event.aggregate.id,\n version: event.version,\n event: JSON.stringify(event),\n created_at: event.at,\n});\n\nexport interface SqlOutboxOptions {\n /**\n * How long a relay holds the entries it took before another may take them —\n * long enough to publish a batch, short enough that a relay that died does\n * not hold its aggregates past the next sweep. Thirty seconds by default.\n */\n readonly leaseSeconds?: number;\n}\n\nconst fault = (error: unknown) => translateSqlError(error);\n\n/**\n * The outbox over Postgres\n * ([ADR 0018](../../../../../docs/adr/0018-one-outbox-ordered-per-aggregate.md)).\n *\n * `pending` **claims** what it returns: it leases the oldest entries of the\n * aggregates no other relay holds, with `FOR UPDATE SKIP LOCKED` and an\n * advisory lock per aggregate taken in the same statement, so several runners\n * drain one table without publishing an entry twice or two versions of one\n * aggregate out of order. A failure gives the whole aggregate back at once, so\n * the next pass retries it rather than waiting out the lease.\n */\nexport const makeSqlOutbox = (\n sql: SqlClient.SqlClient,\n options: SqlOutboxOptions = {},\n): Outbox => {\n const lease = `${String(options.leaseSeconds ?? 30)} seconds`;\n return {\n enqueue: event =>\n sql<{ readonly event_id: string }>`\n INSERT INTO entifix_outbox ${sql.insert(outboxRow(event))}\n ON CONFLICT (event_id) DO NOTHING\n RETURNING event_id`.pipe(\n Effect.map(rows =>\n rows.length === 0 ? ('duplicate' as const) : ('enqueued' as const),\n ),\n Effect.mapError(fault),\n ),\n\n pending: limit =>\n sql<OutboxRow>`\n WITH picked AS (\n SELECT o.event_id FROM entifix_outbox o\n WHERE NOT o.sent AND NOT o.quarantined\n AND (o.lease_until IS NULL OR o.lease_until < now())\n AND NOT EXISTS (\n SELECT 1 FROM entifix_outbox h\n WHERE h.aggregate_type = o.aggregate_type\n AND h.aggregate_id = o.aggregate_id\n AND NOT h.sent AND NOT h.quarantined\n AND h.lease_until >= now()\n )\n AND pg_try_advisory_xact_lock(\n hashtext(o.aggregate_type || ':' || o.aggregate_id)\n )\n ORDER BY o.created_at, o.version\n LIMIT ${limit}\n FOR UPDATE SKIP LOCKED\n )\n UPDATE entifix_outbox AS t\n SET lease_until = now() + ${lease}::interval\n FROM picked WHERE t.event_id = picked.event_id\n RETURNING t.*`.pipe(\n Effect.map(rows =>\n rows\n .map(entryOf)\n .sort(\n (a, b) =>\n a.createdAt.localeCompare(b.createdAt) || a.version - b.version,\n ),\n ),\n Effect.mapError(fault),\n ),\n\n markSent: entry =>\n sql`\n UPDATE entifix_outbox SET sent = true, lease_until = NULL\n WHERE event_id = ${entry.eventId}`.pipe(\n Effect.asVoid,\n Effect.mapError(fault),\n ),\n\n recordFailure: (entry, error, quarantine) =>\n sql`\n UPDATE entifix_outbox\n SET attempts = attempts + 1, last_error = ${error},\n quarantined = ${quarantine}\n WHERE event_id = ${entry.eventId}`.pipe(\n Effect.andThen(\n sql`\n UPDATE entifix_outbox SET lease_until = NULL\n WHERE aggregate_type = ${entry.aggregate.type}\n AND aggregate_id = ${entry.aggregate.id} AND NOT sent`,\n ),\n Effect.asVoid,\n Effect.mapError(fault),\n ),\n\n stats: () =>\n sql<{\n readonly pending: string | number;\n readonly quarantined: string | number;\n readonly oldest: Date | string | null;\n }>`\n SELECT\n count(*) FILTER (WHERE NOT sent AND NOT quarantined) AS pending,\n count(*) FILTER (WHERE quarantined) AS quarantined,\n min(created_at) FILTER (WHERE NOT sent AND NOT quarantined) AS oldest\n FROM entifix_outbox`.pipe(\n Effect.map(([row]) => ({\n pending: Number(row?.pending ?? 0),\n quarantined: Number(row?.quarantined ?? 0),\n ...(row?.oldest == null ? {} : { oldestPendingAt: iso(row.oldest) }),\n })),\n Effect.mapError(fault),\n ),\n };\n};\n"],"names":["Effect","translateSqlError","iso","value","Date","toISOString","entryOf","row","eventId","event_id","aggregate","type","aggregate_type","id","aggregate_id","version","event","JSON","parse","sent","attempts","last_error","lastError","quarantined","createdAt","created_at","outboxRow","stringify","at","fault","error","makeSqlOutbox","sql","options","lease","String","leaseSeconds","enqueue","insert","pipe","map","rows","length","mapError","pending","limit","sort","a","b","localeCompare","markSent","entry","asVoid","recordFailure","quarantine","andThen","stats","Number","oldest","oldestPendingAt"],"mappings":"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"}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { EventBusTag, OutboxMaxAttempts } from '@entifix/transactions';
|
|
2
|
+
import { type OutboxStatsSink, type RelayOutcomeSink } from '@entifix/transactions/engine';
|
|
3
|
+
import { Effect } from 'effect';
|
|
4
|
+
import { SqlClient } from 'effect/sql';
|
|
5
|
+
/**
|
|
6
|
+
* Starts the relay over the service's SQL database: the tables are ensured, a
|
|
7
|
+
* daemon sweeps the outbox, and its last sweep runs in the `flush` phase of a
|
|
8
|
+
* shutdown, after consumers have stopped and while the bus is still open.
|
|
9
|
+
* Several replicas may run it at once — the outbox's claim keeps them apart.
|
|
10
|
+
*/
|
|
11
|
+
export declare const startSqlOutboxRelay: (options: {
|
|
12
|
+
/** What the outbox's outcomes and gauges are labelled with. */
|
|
13
|
+
readonly database: string;
|
|
14
|
+
readonly onStats?: OutboxStatsSink;
|
|
15
|
+
readonly onOutcome?: RelayOutcomeSink;
|
|
16
|
+
}) => Effect.Effect<void, import("@entifix/core").StoreFailure, import("@entifix/business").ShutdownRegistryTag | SqlClient.SqlClient | OutboxMaxAttempts | EventBusTag>;
|
|
17
|
+
/**
|
|
18
|
+
* One drain now, for a write that just committed — the event leaves at once
|
|
19
|
+
* rather than at the next sweep. A failure is reported and left to the sweep.
|
|
20
|
+
*/
|
|
21
|
+
export declare const drainSqlOutboxNow: (database: string, onOutcome?: RelayOutcomeSink) => Effect.Effect<void, never, SqlClient.SqlClient | OutboxMaxAttempts | EventBusTag>;
|
|
22
|
+
//# sourceMappingURL=relay.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"relay.d.ts","sourceRoot":"","sources":["../../src/transactions/relay.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AACvE,OAAO,EAGL,KAAK,eAAe,EACpB,KAAK,gBAAgB,EAGtB,MAAM,8BAA8B,CAAC;AACtC,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAKvC;;;;;GAKG;AACH,eAAO,MAAM,mBAAmB,GAAI,SAAS;IAC3C,+DAA+D;IAC/D,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,OAAO,CAAC,EAAE,eAAe,CAAC;IACnC,QAAQ,CAAC,SAAS,CAAC,EAAE,gBAAgB,CAAC;CACvC,uKAcG,CAAC;AAEL;;;GAGG;AACH,eAAO,MAAM,iBAAiB,GAC5B,UAAU,MAAM,EAChB,YAAW,gBAAqC,sFAe/C,CAAC"}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
import { EventBusTag, OutboxMaxAttempts } from '@entifix/transactions';
|
|
3
|
+
import { drainOutbox, ignoreRelayOutcome, runRelayDaemon, sweepOutbox } from '@entifix/transactions/engine';
|
|
4
|
+
import { Effect } from 'effect';
|
|
5
|
+
import { SqlClient } from 'effect/sql';
|
|
6
|
+
import { makeSqlOutbox } from './outbox.js';
|
|
7
|
+
import { ensureSqlTransactionTables } from './schema.js';
|
|
8
|
+
/**
|
|
9
|
+
* Starts the relay over the service's SQL database: the tables are ensured, a
|
|
10
|
+
* daemon sweeps the outbox, and its last sweep runs in the `flush` phase of a
|
|
11
|
+
* shutdown, after consumers have stopped and while the bus is still open.
|
|
12
|
+
* Several replicas may run it at once — the outbox's claim keeps them apart.
|
|
13
|
+
*/ export const startSqlOutboxRelay = (options)=>Effect.gen(function*() {
|
|
14
|
+
const sql = yield* SqlClient.SqlClient;
|
|
15
|
+
const bus = yield* EventBusTag;
|
|
16
|
+
const maxAttempts = yield* OutboxMaxAttempts;
|
|
17
|
+
yield* ensureSqlTransactionTables(sql);
|
|
18
|
+
yield* runRelayDaemon(sweepOutbox(makeSqlOutbox(sql), bus, _extends({
|
|
19
|
+
maxAttempts,
|
|
20
|
+
database: options.database
|
|
21
|
+
}, options.onStats ? {
|
|
22
|
+
onStats: options.onStats
|
|
23
|
+
} : {}, options.onOutcome ? {
|
|
24
|
+
onOutcome: options.onOutcome
|
|
25
|
+
} : {})));
|
|
26
|
+
});
|
|
27
|
+
/**
|
|
28
|
+
* One drain now, for a write that just committed — the event leaves at once
|
|
29
|
+
* rather than at the next sweep. A failure is reported and left to the sweep.
|
|
30
|
+
*/ export const drainSqlOutboxNow = (database, onOutcome = ignoreRelayOutcome)=>Effect.gen(function*() {
|
|
31
|
+
const sql = yield* SqlClient.SqlClient;
|
|
32
|
+
const bus = yield* EventBusTag;
|
|
33
|
+
const maxAttempts = yield* OutboxMaxAttempts;
|
|
34
|
+
yield* drainOutbox(makeSqlOutbox(sql), bus, {
|
|
35
|
+
maxAttempts,
|
|
36
|
+
database,
|
|
37
|
+
onOutcome
|
|
38
|
+
});
|
|
39
|
+
}).pipe(Effect.catch((failure)=>onOutcome({
|
|
40
|
+
_tag: 'drainFailed',
|
|
41
|
+
database,
|
|
42
|
+
failure
|
|
43
|
+
})));
|
|
44
|
+
|
|
45
|
+
//# sourceMappingURL=relay.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/transactions/relay.ts"],"sourcesContent":["import { EventBusTag, OutboxMaxAttempts } from '@entifix/transactions';\nimport {\n drainOutbox,\n ignoreRelayOutcome,\n type OutboxStatsSink,\n type RelayOutcomeSink,\n runRelayDaemon,\n sweepOutbox,\n} from '@entifix/transactions/engine';\nimport { Effect } from 'effect';\nimport { SqlClient } from 'effect/sql';\n\nimport { makeSqlOutbox } from './outbox.js';\nimport { ensureSqlTransactionTables } from './schema.js';\n\n/**\n * Starts the relay over the service's SQL database: the tables are ensured, a\n * daemon sweeps the outbox, and its last sweep runs in the `flush` phase of a\n * shutdown, after consumers have stopped and while the bus is still open.\n * Several replicas may run it at once — the outbox's claim keeps them apart.\n */\nexport const startSqlOutboxRelay = (options: {\n /** What the outbox's outcomes and gauges are labelled with. */\n readonly database: string;\n readonly onStats?: OutboxStatsSink;\n readonly onOutcome?: RelayOutcomeSink;\n}) =>\n Effect.gen(function* () {\n const sql = yield* SqlClient.SqlClient;\n const bus = yield* EventBusTag;\n const maxAttempts = yield* OutboxMaxAttempts;\n yield* ensureSqlTransactionTables(sql);\n yield* runRelayDaemon(\n sweepOutbox(makeSqlOutbox(sql), bus, {\n maxAttempts,\n database: options.database,\n ...(options.onStats ? { onStats: options.onStats } : {}),\n ...(options.onOutcome ? { onOutcome: options.onOutcome } : {}),\n }),\n );\n });\n\n/**\n * One drain now, for a write that just committed — the event leaves at once\n * rather than at the next sweep. A failure is reported and left to the sweep.\n */\nexport const drainSqlOutboxNow = (\n database: string,\n onOutcome: RelayOutcomeSink = ignoreRelayOutcome,\n) =>\n Effect.gen(function* () {\n const sql = yield* SqlClient.SqlClient;\n const bus = yield* EventBusTag;\n const maxAttempts = yield* OutboxMaxAttempts;\n yield* drainOutbox(makeSqlOutbox(sql), bus, {\n maxAttempts,\n database,\n onOutcome,\n });\n }).pipe(\n Effect.catch(failure =>\n onOutcome({ _tag: 'drainFailed', database, failure }),\n ),\n );\n"],"names":["EventBusTag","OutboxMaxAttempts","drainOutbox","ignoreRelayOutcome","runRelayDaemon","sweepOutbox","Effect","SqlClient","makeSqlOutbox","ensureSqlTransactionTables","startSqlOutboxRelay","options","gen","sql","bus","maxAttempts","database","onStats","onOutcome","drainSqlOutboxNow","pipe","catch","failure","_tag"],"mappings":";AAAA,SAASA,WAAW,EAAEC,iBAAiB,QAAQ,wBAAwB;AACvE,SACEC,WAAW,EACXC,kBAAkB,EAGlBC,cAAc,EACdC,WAAW,QACN,+BAA+B;AACtC,SAASC,MAAM,QAAQ,SAAS;AAChC,SAASC,SAAS,QAAQ,aAAa;AAEvC,SAASC,aAAa,QAAQ,cAAc;AAC5C,SAASC,0BAA0B,QAAQ,cAAc;AAEzD;;;;;CAKC,GACD,OAAO,MAAMC,sBAAsB,CAACC,UAMlCL,OAAOM,GAAG,CAAC;QACT,MAAMC,MAAM,OAAON,UAAUA,SAAS;QACtC,MAAMO,MAAM,OAAOd;QACnB,MAAMe,cAAc,OAAOd;QAC3B,OAAOQ,2BAA2BI;QAClC,OAAOT,eACLC,YAAYG,cAAcK,MAAMC,KAAK;YACnCC;YACAC,UAAUL,QAAQK,QAAQ;WACtBL,QAAQM,OAAO,GAAG;YAAEA,SAASN,QAAQM,OAAO;QAAC,IAAI,CAAC,GAClDN,QAAQO,SAAS,GAAG;YAAEA,WAAWP,QAAQO,SAAS;QAAC,IAAI,CAAC;IAGlE,GAAG;AAEL;;;CAGC,GACD,OAAO,MAAMC,oBAAoB,CAC/BH,UACAE,YAA8Bf,kBAAkB,GAEhDG,OAAOM,GAAG,CAAC;QACT,MAAMC,MAAM,OAAON,UAAUA,SAAS;QACtC,MAAMO,MAAM,OAAOd;QACnB,MAAMe,cAAc,OAAOd;QAC3B,OAAOC,YAAYM,cAAcK,MAAMC,KAAK;YAC1CC;YACAC;YACAE;QACF;IACF,GAAGE,IAAI,CACLd,OAAOe,KAAK,CAACC,CAAAA,UACXJ,UAAU;YAAEK,MAAM;YAAeP;YAAUM;QAAQ,KAErD"}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Effect } from 'effect';
|
|
2
|
+
import type { SqlClient } from 'effect/sql';
|
|
3
|
+
/** The tables the outbox, the inboxes and the idempotency store live in. */
|
|
4
|
+
export declare const SQL_TRANSACTION_TABLES: {
|
|
5
|
+
readonly outbox: "entifix_outbox";
|
|
6
|
+
readonly inbox: "entifix_inbox";
|
|
7
|
+
readonly commandInbox: "entifix_command_inbox";
|
|
8
|
+
readonly idempotency: "entifix_idempotency";
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Creates the outbox, the event and command inboxes and the idempotency table
|
|
12
|
+
* if they are missing. Idempotent, so a service runs it on every boot; Postgres
|
|
13
|
+
* dialect, because the outbox's claim rides `FOR UPDATE SKIP LOCKED` and an
|
|
14
|
+
* advisory lock.
|
|
15
|
+
*/
|
|
16
|
+
export declare const ensureSqlTransactionTables: (sql: SqlClient.SqlClient) => Effect.Effect<void, import("@entifix/core").StoreFailure, never>;
|
|
17
|
+
//# sourceMappingURL=schema.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../../src/transactions/schema.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAI5C,4EAA4E;AAC5E,eAAO,MAAM,sBAAsB;;;;;CAKzB,CAAC;AAEX;;;;;GAKG;AACH,eAAO,MAAM,0BAA0B,GAAI,KAAK,SAAS,CAAC,SAAS,qEAyCN,CAAC"}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { Effect } from 'effect';
|
|
2
|
+
import { translateSqlError } from '../errors/translate-sql-error.js';
|
|
3
|
+
/** The tables the outbox, the inboxes and the idempotency store live in. */ export const SQL_TRANSACTION_TABLES = {
|
|
4
|
+
outbox: 'entifix_outbox',
|
|
5
|
+
inbox: 'entifix_inbox',
|
|
6
|
+
commandInbox: 'entifix_command_inbox',
|
|
7
|
+
idempotency: 'entifix_idempotency'
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Creates the outbox, the event and command inboxes and the idempotency table
|
|
11
|
+
* if they are missing. Idempotent, so a service runs it on every boot; Postgres
|
|
12
|
+
* dialect, because the outbox's claim rides `FOR UPDATE SKIP LOCKED` and an
|
|
13
|
+
* advisory lock.
|
|
14
|
+
*/ export const ensureSqlTransactionTables = (sql)=>Effect.gen(function*() {
|
|
15
|
+
yield* sql`
|
|
16
|
+
CREATE TABLE IF NOT EXISTS entifix_outbox (
|
|
17
|
+
event_id text PRIMARY KEY,
|
|
18
|
+
aggregate_type text NOT NULL,
|
|
19
|
+
aggregate_id text NOT NULL,
|
|
20
|
+
version integer NOT NULL,
|
|
21
|
+
event jsonb NOT NULL,
|
|
22
|
+
sent boolean NOT NULL DEFAULT false,
|
|
23
|
+
attempts integer NOT NULL DEFAULT 0,
|
|
24
|
+
last_error text,
|
|
25
|
+
quarantined boolean NOT NULL DEFAULT false,
|
|
26
|
+
created_at timestamptz NOT NULL,
|
|
27
|
+
lease_until timestamptz
|
|
28
|
+
)`;
|
|
29
|
+
yield* sql`
|
|
30
|
+
CREATE INDEX IF NOT EXISTS entifix_outbox_pending
|
|
31
|
+
ON entifix_outbox (created_at, version)
|
|
32
|
+
WHERE NOT sent AND NOT quarantined`;
|
|
33
|
+
yield* sql`
|
|
34
|
+
CREATE TABLE IF NOT EXISTS entifix_inbox (
|
|
35
|
+
consumer text NOT NULL,
|
|
36
|
+
event_id text NOT NULL,
|
|
37
|
+
claimed_at timestamptz NOT NULL DEFAULT now(),
|
|
38
|
+
PRIMARY KEY (consumer, event_id)
|
|
39
|
+
)`;
|
|
40
|
+
yield* sql`
|
|
41
|
+
CREATE TABLE IF NOT EXISTS entifix_command_inbox (
|
|
42
|
+
command_id text PRIMARY KEY,
|
|
43
|
+
claimed_at timestamptz NOT NULL DEFAULT now(),
|
|
44
|
+
replay jsonb
|
|
45
|
+
)`;
|
|
46
|
+
yield* sql`
|
|
47
|
+
CREATE TABLE IF NOT EXISTS entifix_idempotency (
|
|
48
|
+
dedup_key text PRIMARY KEY,
|
|
49
|
+
fingerprint text NOT NULL,
|
|
50
|
+
state text NOT NULL,
|
|
51
|
+
response jsonb,
|
|
52
|
+
expires_at timestamptz NOT NULL
|
|
53
|
+
)`;
|
|
54
|
+
}).pipe(Effect.mapError((error)=>translateSqlError(error)));
|
|
55
|
+
|
|
56
|
+
//# sourceMappingURL=schema.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/transactions/schema.ts"],"sourcesContent":["import { Effect } from 'effect';\nimport type { SqlClient } from 'effect/sql';\n\nimport { translateSqlError } from '../errors/translate-sql-error.js';\n\n/** The tables the outbox, the inboxes and the idempotency store live in. */\nexport const SQL_TRANSACTION_TABLES = {\n outbox: 'entifix_outbox',\n inbox: 'entifix_inbox',\n commandInbox: 'entifix_command_inbox',\n idempotency: 'entifix_idempotency',\n} as const;\n\n/**\n * Creates the outbox, the event and command inboxes and the idempotency table\n * if they are missing. Idempotent, so a service runs it on every boot; Postgres\n * dialect, because the outbox's claim rides `FOR UPDATE SKIP LOCKED` and an\n * advisory lock.\n */\nexport const ensureSqlTransactionTables = (sql: SqlClient.SqlClient) =>\n Effect.gen(function* () {\n yield* sql`\n CREATE TABLE IF NOT EXISTS entifix_outbox (\n event_id text PRIMARY KEY,\n aggregate_type text NOT NULL,\n aggregate_id text NOT NULL,\n version integer NOT NULL,\n event jsonb NOT NULL,\n sent boolean NOT NULL DEFAULT false,\n attempts integer NOT NULL DEFAULT 0,\n last_error text,\n quarantined boolean NOT NULL DEFAULT false,\n created_at timestamptz NOT NULL,\n lease_until timestamptz\n )`;\n yield* sql`\n CREATE INDEX IF NOT EXISTS entifix_outbox_pending\n ON entifix_outbox (created_at, version)\n WHERE NOT sent AND NOT quarantined`;\n yield* sql`\n CREATE TABLE IF NOT EXISTS entifix_inbox (\n consumer text NOT NULL,\n event_id text NOT NULL,\n claimed_at timestamptz NOT NULL DEFAULT now(),\n PRIMARY KEY (consumer, event_id)\n )`;\n yield* sql`\n CREATE TABLE IF NOT EXISTS entifix_command_inbox (\n command_id text PRIMARY KEY,\n claimed_at timestamptz NOT NULL DEFAULT now(),\n replay jsonb\n )`;\n yield* sql`\n CREATE TABLE IF NOT EXISTS entifix_idempotency (\n dedup_key text PRIMARY KEY,\n fingerprint text NOT NULL,\n state text NOT NULL,\n response jsonb,\n expires_at timestamptz NOT NULL\n )`;\n }).pipe(Effect.mapError(error => translateSqlError(error)));\n"],"names":["Effect","translateSqlError","SQL_TRANSACTION_TABLES","outbox","inbox","commandInbox","idempotency","ensureSqlTransactionTables","sql","gen","pipe","mapError","error"],"mappings":"AAAA,SAASA,MAAM,QAAQ,SAAS;AAGhC,SAASC,iBAAiB,QAAQ,mCAAmC;AAErE,0EAA0E,GAC1E,OAAO,MAAMC,yBAAyB;IACpCC,QAAQ;IACRC,OAAO;IACPC,cAAc;IACdC,aAAa;AACf,EAAW;AAEX;;;;;CAKC,GACD,OAAO,MAAMC,6BAA6B,CAACC,MACzCR,OAAOS,GAAG,CAAC;QACT,OAAOD,GAAG,CAAC;;;;;;;;;;;;;OAaR,CAAC;QACJ,OAAOA,GAAG,CAAC;;;0CAG2B,CAAC;QACvC,OAAOA,GAAG,CAAC;;;;;;OAMR,CAAC;QACJ,OAAOA,GAAG,CAAC;;;;;OAKR,CAAC;QACJ,OAAOA,GAAG,CAAC;;;;;;;OAOR,CAAC;IACN,GAAGE,IAAI,CAACV,OAAOW,QAAQ,CAACC,CAAAA,QAASX,kBAAkBW,SAAS"}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { UnitOfWork } from '@entifix/transactions';
|
|
2
|
+
import { type SqlClient } from 'effect/sql';
|
|
3
|
+
/** What a unit's body writes through: the client, inside the unit's transaction. */
|
|
4
|
+
export interface SqlUnit {
|
|
5
|
+
readonly sql: SqlClient.SqlClient;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Units of work over one SQL database
|
|
9
|
+
* ([ADR 0009](../../../../../docs/adr/0009-a-unit-of-work-the-outbox-inbox-and-command-claim-join.md)):
|
|
10
|
+
* the body's writes, the events it emitted and its claim are one transaction.
|
|
11
|
+
* A taken claim, or an event id the outbox already holds, rolls back and
|
|
12
|
+
* answers `duplicate`; a business failure rolls back and answers `aborted`; a
|
|
13
|
+
* store failure is an error; a serialization failure runs the whole unit again,
|
|
14
|
+
* body included, which is why the body must be retry-safe.
|
|
15
|
+
*/
|
|
16
|
+
export declare const makeSqlUnitOfWork: (sql: SqlClient.SqlClient) => UnitOfWork<SqlUnit>;
|
|
17
|
+
//# sourceMappingURL=unit-of-work.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"unit-of-work.d.ts","sourceRoot":"","sources":["../../src/transactions/unit-of-work.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAGV,UAAU,EAGX,MAAM,uBAAuB,CAAC;AAE/B,OAAO,EAAE,KAAK,SAAS,EAAY,MAAM,YAAY,CAAC;AAKtD,oFAAoF;AACpF,MAAM,WAAW,OAAO;IACtB,QAAQ,CAAC,GAAG,EAAE,SAAS,CAAC,SAAS,CAAC;CACnC;AAwCD;;;;;;;;GAQG;AACH,eAAO,MAAM,iBAAiB,GAC5B,KAAK,SAAS,CAAC,SAAS,KACvB,UAAU,CAAC,OAAO,CAuGpB,CAAC"}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { Contention, isStoreFault } from '@entifix/core';
|
|
2
|
+
import { Data, Effect, Option, Schedule } from 'effect';
|
|
3
|
+
import { SqlError } from 'effect/sql';
|
|
4
|
+
import { translateSqlError } from '../errors/translate-sql-error.js';
|
|
5
|
+
import { outboxRow } from './outbox.js';
|
|
6
|
+
/** The claim was taken, or an emitted event was already recorded: roll back. */ let Duplicate = class Duplicate extends Data.TaggedError('SqlUnitDuplicate') {
|
|
7
|
+
};
|
|
8
|
+
/** The body refused with a business failure: roll back, and answer it. */ let Aborted = class Aborted extends Data.TaggedError('SqlUnitAborted') {
|
|
9
|
+
};
|
|
10
|
+
/** A serialization failure or a deadlock: the whole unit runs again. */ const ATTEMPTS = 3;
|
|
11
|
+
const BACK_OFF = Schedule.exponential('20 millis');
|
|
12
|
+
const claimed = (sql, claim)=>claim.kind === 'event' ? sql`
|
|
13
|
+
INSERT INTO entifix_inbox (consumer, event_id)
|
|
14
|
+
VALUES (${claim.consumer}, ${claim.eventId})
|
|
15
|
+
ON CONFLICT DO NOTHING RETURNING event_id` : sql`
|
|
16
|
+
INSERT INTO entifix_command_inbox (command_id)
|
|
17
|
+
VALUES (${claim.commandId})
|
|
18
|
+
ON CONFLICT DO NOTHING RETURNING command_id`;
|
|
19
|
+
const readReplay = (sql, commandId)=>sql`
|
|
20
|
+
SELECT replay FROM entifix_command_inbox WHERE command_id = ${commandId}`.pipe(Effect.map(([row])=>row === undefined || row.replay == null ? Option.none() : Option.some(typeof row.replay === 'string' ? JSON.parse(row.replay) : row.replay)), Effect.mapError((error)=>translateSqlError(error)));
|
|
21
|
+
/**
|
|
22
|
+
* Units of work over one SQL database
|
|
23
|
+
* ([ADR 0009](../../../../../docs/adr/0009-a-unit-of-work-the-outbox-inbox-and-command-claim-join.md)):
|
|
24
|
+
* the body's writes, the events it emitted and its claim are one transaction.
|
|
25
|
+
* A taken claim, or an event id the outbox already holds, rolls back and
|
|
26
|
+
* answers `duplicate`; a business failure rolls back and answers `aborted`; a
|
|
27
|
+
* store failure is an error; a serialization failure runs the whole unit again,
|
|
28
|
+
* body included, which is why the body must be retry-safe.
|
|
29
|
+
*/ export const makeSqlUnitOfWork = (sql)=>{
|
|
30
|
+
const attempt = (body, options)=>sql.withTransaction(Effect.gen(function*() {
|
|
31
|
+
const claim = options == null ? void 0 : options.claim;
|
|
32
|
+
if (claim !== undefined) {
|
|
33
|
+
const rows = yield* claimed(sql, claim);
|
|
34
|
+
if (rows.length === 0) return yield* Effect.fail(new Duplicate());
|
|
35
|
+
}
|
|
36
|
+
const emitted = [];
|
|
37
|
+
const value = yield* body({
|
|
38
|
+
handle: {
|
|
39
|
+
sql
|
|
40
|
+
},
|
|
41
|
+
emit: (event)=>void emitted.push(event)
|
|
42
|
+
}).pipe(// The body's own driver failure is translated first: an outage
|
|
43
|
+
// fails the unit, while a unique violation is the refusal it would
|
|
44
|
+
// be from any adapter, and aborts it.
|
|
45
|
+
Effect.mapError((failure)=>{
|
|
46
|
+
const translated = SqlError.isSqlError(failure) ? translateSqlError(failure) : failure;
|
|
47
|
+
return isStoreFault(translated) ? translated : new Aborted({
|
|
48
|
+
reason: translated
|
|
49
|
+
});
|
|
50
|
+
}));
|
|
51
|
+
if (emitted.length > 0) {
|
|
52
|
+
const written = yield* sql`
|
|
53
|
+
INSERT INTO entifix_outbox ${sql.insert(emitted.map(outboxRow))}
|
|
54
|
+
ON CONFLICT (event_id) DO NOTHING RETURNING event_id`;
|
|
55
|
+
// An event id already recorded is the outbox's own idempotency
|
|
56
|
+
// claim: what it announces committed before.
|
|
57
|
+
if (written.length < emitted.length) return yield* Effect.fail(new Duplicate());
|
|
58
|
+
}
|
|
59
|
+
if ((claim == null ? void 0 : claim.kind) === 'command' && (options == null ? void 0 : options.replay) !== undefined) yield* sql`
|
|
60
|
+
UPDATE entifix_command_inbox
|
|
61
|
+
SET replay = ${JSON.stringify(options.replay(value))}::jsonb
|
|
62
|
+
WHERE command_id = ${claim.commandId}`;
|
|
63
|
+
return value;
|
|
64
|
+
})).pipe(Effect.map((value)=>({
|
|
65
|
+
_tag: 'committed',
|
|
66
|
+
value
|
|
67
|
+
})), Effect.catch((failure)=>{
|
|
68
|
+
var _options_claim;
|
|
69
|
+
return failure instanceof Duplicate ? Effect.map((options == null ? void 0 : (_options_claim = options.claim) == null ? void 0 : _options_claim.kind) === 'command' ? readReplay(sql, options.claim.commandId) : Effect.succeed(Option.none()), (replay)=>({
|
|
70
|
+
_tag: 'duplicate',
|
|
71
|
+
replay
|
|
72
|
+
})) : failure instanceof Aborted ? Effect.succeed({
|
|
73
|
+
_tag: 'aborted',
|
|
74
|
+
reason: failure.reason
|
|
75
|
+
}) : // connection lost, a serialization conflict at commit. A
|
|
76
|
+
// unique violation cannot: the claim and the outbox insert
|
|
77
|
+
// both read a conflict as a duplicate, and the body's own is
|
|
78
|
+
// aborted above.
|
|
79
|
+
Effect.fail(translateSqlError(failure));
|
|
80
|
+
}));
|
|
81
|
+
return {
|
|
82
|
+
run: (body, options)=>Effect.gen(function*() {
|
|
83
|
+
const outcome = yield* attempt(body, options).pipe(Effect.retry({
|
|
84
|
+
times: ATTEMPTS - 1,
|
|
85
|
+
while: (failure)=>failure instanceof Contention,
|
|
86
|
+
schedule: BACK_OFF
|
|
87
|
+
}));
|
|
88
|
+
if (outcome._tag === 'committed' && (options == null ? void 0 : options.afterCommit)) yield* options.afterCommit.pipe(Effect.ignore);
|
|
89
|
+
return outcome;
|
|
90
|
+
}),
|
|
91
|
+
replay: (commandId)=>readReplay(sql, commandId)
|
|
92
|
+
};
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
//# sourceMappingURL=unit-of-work.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/transactions/unit-of-work.ts"],"sourcesContent":["import { Contention, isStoreFault, type StoreFault } from '@entifix/core';\nimport type {\n OutboxEvent,\n UnitClaim,\n UnitOfWork,\n UnitOutcome,\n UnitRunOptions,\n} from '@entifix/transactions';\nimport { Data, Effect, Option, Schedule } from 'effect';\nimport { type SqlClient, SqlError } from 'effect/sql';\n\nimport { translateSqlError } from '../errors/translate-sql-error.js';\nimport { outboxRow } from './outbox.js';\n\n/** What a unit's body writes through: the client, inside the unit's transaction. */\nexport interface SqlUnit {\n readonly sql: SqlClient.SqlClient;\n}\n\n/** The claim was taken, or an emitted event was already recorded: roll back. */\nclass Duplicate extends Data.TaggedError('SqlUnitDuplicate') {}\n\n/** The body refused with a business failure: roll back, and answer it. */\nclass Aborted extends Data.TaggedError('SqlUnitAborted')<{\n readonly reason: unknown;\n}> {}\n\n/** A serialization failure or a deadlock: the whole unit runs again. */\nconst ATTEMPTS = 3;\nconst BACK_OFF = Schedule.exponential('20 millis');\n\nconst claimed = (sql: SqlClient.SqlClient, claim: UnitClaim) =>\n claim.kind === 'event'\n ? sql<{ readonly event_id: string }>`\n INSERT INTO entifix_inbox (consumer, event_id)\n VALUES (${claim.consumer}, ${claim.eventId})\n ON CONFLICT DO NOTHING RETURNING event_id`\n : sql<{ readonly command_id: string }>`\n INSERT INTO entifix_command_inbox (command_id)\n VALUES (${claim.commandId})\n ON CONFLICT DO NOTHING RETURNING command_id`;\n\nconst readReplay = (sql: SqlClient.SqlClient, commandId: string) =>\n sql<{ readonly replay: unknown }>`\n SELECT replay FROM entifix_command_inbox WHERE command_id = ${commandId}`.pipe(\n Effect.map(([row]) =>\n row === undefined || row.replay == null\n ? Option.none()\n : Option.some(\n typeof row.replay === 'string'\n ? (JSON.parse(row.replay) as unknown)\n : row.replay,\n ),\n ),\n Effect.mapError(error => translateSqlError(error) as StoreFault),\n );\n\n/**\n * Units of work over one SQL database\n * ([ADR 0009](../../../../../docs/adr/0009-a-unit-of-work-the-outbox-inbox-and-command-claim-join.md)):\n * the body's writes, the events it emitted and its claim are one transaction.\n * A taken claim, or an event id the outbox already holds, rolls back and\n * answers `duplicate`; a business failure rolls back and answers `aborted`; a\n * store failure is an error; a serialization failure runs the whole unit again,\n * body included, which is why the body must be retry-safe.\n */\nexport const makeSqlUnitOfWork = (\n sql: SqlClient.SqlClient,\n): UnitOfWork<SqlUnit> => {\n const attempt = <A, E, R>(\n body: (unit: {\n readonly handle: SqlUnit;\n emit(event: OutboxEvent): void;\n }) => Effect.Effect<A, E, R>,\n options: UnitRunOptions<A, R> | undefined,\n ) =>\n sql\n .withTransaction(\n Effect.gen(function* () {\n const claim = options?.claim;\n if (claim !== undefined) {\n const rows = yield* claimed(sql, claim);\n if (rows.length === 0) return yield* Effect.fail(new Duplicate());\n }\n\n const emitted: OutboxEvent[] = [];\n const value = yield* body({\n handle: { sql },\n emit: event => void emitted.push(event),\n }).pipe(\n // The body's own driver failure is translated first: an outage\n // fails the unit, while a unique violation is the refusal it would\n // be from any adapter, and aborts it.\n Effect.mapError(failure => {\n const translated = SqlError.isSqlError(failure)\n ? translateSqlError(failure)\n : failure;\n return isStoreFault(translated)\n ? translated\n : new Aborted({ reason: translated });\n }),\n );\n\n if (emitted.length > 0) {\n const written = yield* sql<{ readonly event_id: string }>`\n INSERT INTO entifix_outbox ${sql.insert(emitted.map(outboxRow))}\n ON CONFLICT (event_id) DO NOTHING RETURNING event_id`;\n // An event id already recorded is the outbox's own idempotency\n // claim: what it announces committed before.\n if (written.length < emitted.length)\n return yield* Effect.fail(new Duplicate());\n }\n\n if (claim?.kind === 'command' && options?.replay !== undefined)\n yield* sql`\n UPDATE entifix_command_inbox\n SET replay = ${JSON.stringify(options.replay(value))}::jsonb\n WHERE command_id = ${claim.commandId}`;\n return value;\n }),\n )\n .pipe(\n Effect.map(\n value =>\n ({ _tag: 'committed', value }) as UnitOutcome<\n A,\n Exclude<E, StoreFault>\n >,\n ),\n Effect.catch(failure =>\n failure instanceof Duplicate\n ? Effect.map(\n options?.claim?.kind === 'command'\n ? readReplay(sql, options.claim.commandId)\n : Effect.succeed(Option.none()),\n replay =>\n ({ _tag: 'duplicate', replay }) as UnitOutcome<\n A,\n Exclude<E, StoreFault>\n >,\n )\n : failure instanceof Aborted\n ? Effect.succeed({\n _tag: 'aborted',\n reason: failure.reason as Exclude<E, StoreFault>,\n } as UnitOutcome<A, Exclude<E, StoreFault>>)\n : // What reaches here is the transaction's own failure — a\n // connection lost, a serialization conflict at commit. A\n // unique violation cannot: the claim and the outbox insert\n // both read a conflict as a duplicate, and the body's own is\n // aborted above.\n Effect.fail(translateSqlError(failure) as StoreFault),\n ),\n );\n\n return {\n run: (body, options) =>\n Effect.gen(function* () {\n const outcome = yield* attempt(body, options).pipe(\n Effect.retry({\n times: ATTEMPTS - 1,\n while: failure => failure instanceof Contention,\n schedule: BACK_OFF,\n }),\n );\n if (outcome._tag === 'committed' && options?.afterCommit)\n yield* options.afterCommit.pipe(Effect.ignore);\n return outcome;\n }),\n replay: commandId => readReplay(sql, commandId),\n };\n};\n"],"names":["Contention","isStoreFault","Data","Effect","Option","Schedule","SqlError","translateSqlError","outboxRow","Duplicate","TaggedError","Aborted","ATTEMPTS","BACK_OFF","exponential","claimed","sql","claim","kind","consumer","eventId","commandId","readReplay","pipe","map","row","undefined","replay","none","some","JSON","parse","mapError","error","makeSqlUnitOfWork","attempt","body","options","withTransaction","gen","rows","length","fail","emitted","value","handle","emit","event","push","failure","translated","isSqlError","reason","written","insert","stringify","_tag","catch","succeed","run","outcome","retry","times","while","schedule","afterCommit","ignore"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@entifix/sql",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.1",
|
|
4
4
|
"description": "SQL adapter over @effect/sql: an entity repository with RSQL filter and sort translation, and a health probe.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -21,6 +21,11 @@
|
|
|
21
21
|
"types": "./dist/index.d.ts",
|
|
22
22
|
"import": "./dist/index.js",
|
|
23
23
|
"default": "./dist/index.js"
|
|
24
|
+
},
|
|
25
|
+
"./transactions": {
|
|
26
|
+
"types": "./dist/transactions/index.d.ts",
|
|
27
|
+
"import": "./dist/transactions/index.js",
|
|
28
|
+
"default": "./dist/transactions/index.js"
|
|
24
29
|
}
|
|
25
30
|
},
|
|
26
31
|
"files": [
|
|
@@ -46,19 +51,34 @@
|
|
|
46
51
|
"skipTypeCheck": true,
|
|
47
52
|
"stripLeadingPaths": true
|
|
48
53
|
}
|
|
54
|
+
},
|
|
55
|
+
"test-integration": {
|
|
56
|
+
"executor": "nx:run-commands",
|
|
57
|
+
"options": {
|
|
58
|
+
"command": "vitest run --config vitest.integration.mts",
|
|
59
|
+
"cwd": "packages/ts/sql-client"
|
|
60
|
+
}
|
|
49
61
|
}
|
|
50
62
|
}
|
|
51
63
|
},
|
|
52
64
|
"dependencies": {
|
|
53
65
|
"@swc/helpers": "0.5.23",
|
|
54
|
-
"@entifix/
|
|
55
|
-
"@entifix/
|
|
66
|
+
"@entifix/business": "0.12.1",
|
|
67
|
+
"@entifix/core": "0.12.1"
|
|
56
68
|
},
|
|
57
69
|
"devDependencies": {
|
|
70
|
+
"@effect/sql-pg": "4.0.6",
|
|
58
71
|
"effect": "4.0.6",
|
|
59
|
-
"@entifix/testing-unit": "0.
|
|
72
|
+
"@entifix/testing-unit": "0.12.1",
|
|
73
|
+
"@entifix/transactions": "0.12.1"
|
|
60
74
|
},
|
|
61
75
|
"peerDependencies": {
|
|
62
|
-
"effect": "^4.0.6"
|
|
76
|
+
"effect": "^4.0.6",
|
|
77
|
+
"@entifix/transactions": "0.12.1"
|
|
78
|
+
},
|
|
79
|
+
"peerDependenciesMeta": {
|
|
80
|
+
"@entifix/transactions": {
|
|
81
|
+
"optional": true
|
|
82
|
+
}
|
|
63
83
|
}
|
|
64
84
|
}
|