@entifix/rest 0.10.2 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/entity-rest/build-entity-rest-adapter-mixins/build-entity-request.d.ts +4 -2
- package/dist/adapters/entity-rest/build-entity-rest-adapter-mixins/build-entity-request.d.ts.map +1 -1
- package/dist/adapters/entity-rest/build-entity-rest-adapter-mixins/build-entity-request.js +12 -5
- package/dist/adapters/entity-rest/build-entity-rest-adapter-mixins/build-entity-request.js.map +1 -1
- package/dist/adapters/entity-rest/build-entity-rest-adapter-mixins/index.d.ts +1 -1
- package/dist/adapters/entity-rest/build-entity-rest-adapter-save/build-entity-rest-adapter-save.d.ts.map +1 -1
- package/dist/adapters/entity-rest/build-entity-rest-adapter-save/build-entity-rest-adapter-save.js +29 -5
- package/dist/adapters/entity-rest/build-entity-rest-adapter-save/build-entity-rest-adapter-save.js.map +1 -1
- package/package.json +4 -4
package/dist/adapters/entity-rest/build-entity-rest-adapter-mixins/build-entity-request.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type EntifixEnvelope } from '@entifix/core';
|
|
2
2
|
import { HttpMethod, HttpRequest } from '../../../clients/types.js';
|
|
3
3
|
export interface BuildEntityRequestOptions {
|
|
4
4
|
method: HttpMethod;
|
|
@@ -8,6 +8,8 @@ export interface BuildEntityRequestOptions {
|
|
|
8
8
|
* Omitted for reads and deletes, which carry no payload.
|
|
9
9
|
*/
|
|
10
10
|
envelope?: EntifixEnvelope<unknown>;
|
|
11
|
+
/** A write's `Idempotency-Key`, already minted by the caller. */
|
|
12
|
+
idempotencyKey?: string;
|
|
11
13
|
}
|
|
12
14
|
/**
|
|
13
15
|
* Assembles the {@link HttpRequest} the fetch client executes. Centralized so
|
|
@@ -15,5 +17,5 @@ export interface BuildEntityRequestOptions {
|
|
|
15
17
|
* body is always an {@link EntifixEnvelope}, and that the JSON content type is
|
|
16
18
|
* declared only when there is one to declare.
|
|
17
19
|
*/
|
|
18
|
-
export declare const buildEntityRequest: ({ method, url, envelope, }: BuildEntityRequestOptions) => HttpRequest<EntifixEnvelope<unknown>>;
|
|
20
|
+
export declare const buildEntityRequest: ({ method, url, envelope, idempotencyKey, }: BuildEntityRequestOptions) => HttpRequest<EntifixEnvelope<unknown>>;
|
|
19
21
|
//# sourceMappingURL=build-entity-request.d.ts.map
|
package/dist/adapters/entity-rest/build-entity-rest-adapter-mixins/build-entity-request.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"build-entity-request.d.ts","sourceRoot":"","sources":["../../../../src/adapters/entity-rest/build-entity-rest-adapter-mixins/build-entity-request.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,
|
|
1
|
+
{"version":3,"file":"build-entity-request.d.ts","sourceRoot":"","sources":["../../../../src/adapters/entity-rest/build-entity-rest-adapter-mixins/build-entity-request.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,eAAe,EAAwB,MAAM,eAAe,CAAC;AAE3E,OAAO,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,2BAA2B,CAAC;AAEpE,MAAM,WAAW,yBAAyB;IACxC,MAAM,EAAE,UAAU,CAAC;IACnB,GAAG,EAAE,MAAM,CAAC;IACZ;;;OAGG;IACH,QAAQ,CAAC,EAAE,eAAe,CAAC,OAAO,CAAC,CAAC;IACpC,iEAAiE;IACjE,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED;;;;;GAKG;AACH,eAAO,MAAM,kBAAkB,GAAI,4CAKhC,yBAAyB,KAAG,WAAW,CAAC,eAAe,CAAC,OAAO,CAAC,CAalE,CAAC"}
|
|
@@ -1,17 +1,24 @@
|
|
|
1
1
|
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
import { formatIdempotencyKey } from '@entifix/core';
|
|
2
3
|
/**
|
|
3
4
|
* Assembles the {@link HttpRequest} the fetch client executes. Centralized so
|
|
4
5
|
* the read and write adapters agree on how a message is framed — notably that a
|
|
5
6
|
* body is always an {@link EntifixEnvelope}, and that the JSON content type is
|
|
6
7
|
* declared only when there is one to declare.
|
|
7
|
-
*/ export const buildEntityRequest = ({ method, url, envelope })=>
|
|
8
|
+
*/ export const buildEntityRequest = ({ method, url, envelope, idempotencyKey })=>{
|
|
9
|
+
const headers = _extends({}, envelope ? {
|
|
10
|
+
'Content-Type': 'application/json'
|
|
11
|
+
} : {}, idempotencyKey === undefined ? {} : {
|
|
12
|
+
'Idempotency-Key': formatIdempotencyKey(idempotencyKey)
|
|
13
|
+
});
|
|
14
|
+
return _extends({
|
|
8
15
|
method,
|
|
9
16
|
url
|
|
10
|
-
},
|
|
11
|
-
headers
|
|
12
|
-
|
|
13
|
-
},
|
|
17
|
+
}, Object.keys(headers).length > 0 ? {
|
|
18
|
+
headers
|
|
19
|
+
} : {}, envelope ? {
|
|
14
20
|
body: envelope
|
|
15
21
|
} : {});
|
|
22
|
+
};
|
|
16
23
|
|
|
17
24
|
//# sourceMappingURL=build-entity-request.js.map
|
package/dist/adapters/entity-rest/build-entity-rest-adapter-mixins/build-entity-request.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../src/adapters/entity-rest/build-entity-rest-adapter-mixins/build-entity-request.ts"],"sourcesContent":["import type
|
|
1
|
+
{"version":3,"sources":["../../../../src/adapters/entity-rest/build-entity-rest-adapter-mixins/build-entity-request.ts"],"sourcesContent":["import { type EntifixEnvelope, formatIdempotencyKey } from '@entifix/core';\n\nimport { HttpMethod, HttpRequest } from '../../../clients/types.js';\n\nexport interface BuildEntityRequestOptions {\n method: HttpMethod;\n url: string;\n /**\n * The body, already wrapped as an envelope — every entifix message is one.\n * Omitted for reads and deletes, which carry no payload.\n */\n envelope?: EntifixEnvelope<unknown>;\n /** A write's `Idempotency-Key`, already minted by the caller. */\n idempotencyKey?: string;\n}\n\n/**\n * Assembles the {@link HttpRequest} the fetch client executes. Centralized so\n * the read and write adapters agree on how a message is framed — notably that a\n * body is always an {@link EntifixEnvelope}, and that the JSON content type is\n * declared only when there is one to declare.\n */\nexport const buildEntityRequest = ({\n method,\n url,\n envelope,\n idempotencyKey,\n}: BuildEntityRequestOptions): HttpRequest<EntifixEnvelope<unknown>> => {\n const headers: Record<string, string> = {\n ...(envelope ? { 'Content-Type': 'application/json' } : {}),\n ...(idempotencyKey === undefined\n ? {}\n : { 'Idempotency-Key': formatIdempotencyKey(idempotencyKey) }),\n };\n return {\n method,\n url,\n ...(Object.keys(headers).length > 0 ? { headers } : {}),\n ...(envelope ? { body: envelope } : {}),\n };\n};\n"],"names":["formatIdempotencyKey","buildEntityRequest","method","url","envelope","idempotencyKey","headers","undefined","Object","keys","length","body"],"mappings":";AAAA,SAA+BA,oBAAoB,QAAQ,gBAAgB;AAgB3E;;;;;CAKC,GACD,OAAO,MAAMC,qBAAqB,CAAC,EACjCC,MAAM,EACNC,GAAG,EACHC,QAAQ,EACRC,cAAc,EACY;IAC1B,MAAMC,UAAkC,aAClCF,WAAW;QAAE,gBAAgB;IAAmB,IAAI,CAAC,GACrDC,mBAAmBE,YACnB,CAAC,IACD;QAAE,mBAAmBP,qBAAqBK;IAAgB;IAEhE,OAAO;QACLH;QACAC;OACIK,OAAOC,IAAI,CAACH,SAASI,MAAM,GAAG,IAAI;QAAEJ;IAAQ,IAAI,CAAC,GACjDF,WAAW;QAAEO,MAAMP;IAAS,IAAI,CAAC;AAEzC,EAAE"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export declare const buildEntityRestAdapterMixins: {
|
|
2
2
|
buildEntityBaseUrl: (configurationStore: import("@entifix/core").ConfigurationClient, options: Pick<import("../types.js").BuildEntityRestOptions, "uriConfig">, entityName: string, entityId?: string) => import("effect/Effect").Effect<string, import("@entifix/core").EntifixBuildError, never>;
|
|
3
|
-
buildEntityRequest: ({ method, url, envelope, }: import("./build-entity-request.js").BuildEntityRequestOptions) => import("../../../index.js").HttpRequest<import("@entifix/core").EntifixEnvelope<unknown>>;
|
|
3
|
+
buildEntityRequest: ({ method, url, envelope, idempotencyKey, }: import("./build-entity-request.js").BuildEntityRequestOptions) => import("../../../index.js").HttpRequest<import("@entifix/core").EntifixEnvelope<unknown>>;
|
|
4
4
|
};
|
|
5
5
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"build-entity-rest-adapter-save.d.ts","sourceRoot":"","sources":["../../../../src/adapters/entity-rest/build-entity-rest-adapter-save/build-entity-rest-adapter-save.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,MAAM,EACN,iBAAiB,EAKlB,MAAM,eAAe,CAAC;AAOvB,OAAO,EAAE,MAAM,EAAU,MAAM,QAAQ,CAAC;AAIxC,OAAO,EAAE,sBAAsB,EAAE,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"build-entity-rest-adapter-save.d.ts","sourceRoot":"","sources":["../../../../src/adapters/entity-rest/build-entity-rest-adapter-save/build-entity-rest-adapter-save.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,MAAM,EACN,iBAAiB,EAKlB,MAAM,eAAe,CAAC;AAOvB,OAAO,EAAE,MAAM,EAAU,MAAM,QAAQ,CAAC;AAIxC,OAAO,EAAE,sBAAsB,EAAE,MAAM,aAAa,CAAC;AAkBrD;;;;;;;;;;;;;;;;;;GAkBG;AACH,eAAO,MAAM,0BAA0B,GACpC,OAAO,SAAS,MAAM,EACrB,mBAAmB,iBAAiB,CAAC,OAAO,CAAC,EAC7C,sCAAiD,sBAAsB,MAKxE,MAAM,SAAS,MAAM,EAAE,QAAQ,MAAM,8KA4FlC,CAAC"}
|
package/dist/adapters/entity-rest/build-entity-rest-adapter-save/build-entity-rest-adapter-save.js
CHANGED
|
@@ -3,6 +3,18 @@ import { makeCommandEnvelope, readTransactionAcceptedEnvelope, TransactionSinkTa
|
|
|
3
3
|
import { Effect, Option } from 'effect';
|
|
4
4
|
import { performHttpRequestThroughFetch } from '../../../clients/fetch/index.js';
|
|
5
5
|
import { buildEntityRestAdapterMixins as adapterMixins } from '../build-entity-rest-adapter-mixins/index.js';
|
|
6
|
+
/**
|
|
7
|
+
* The `Idempotency-Key` each entity's pending save was sent under, kept until
|
|
8
|
+
* that save succeeds.
|
|
9
|
+
*
|
|
10
|
+
* Keyed by the entity instance, so a save called again on the same object
|
|
11
|
+
* after a network error — the case the key exists for — sends the same key and
|
|
12
|
+
* the service replays its first answer instead of writing twice. A save that
|
|
13
|
+
* succeeded forgets its key, so the next edit of that object is a new request.
|
|
14
|
+
* The browser itself never retries (ADR 0014); this is what makes a person's
|
|
15
|
+
* retry safe.
|
|
16
|
+
*/ const pendingKeys = new WeakMap();
|
|
17
|
+
/** A transactional create whose `202` never arrived: same command, same id. */ const pendingCreates = new WeakSet();
|
|
6
18
|
/**
|
|
7
19
|
* Persists an entity over HTTP.
|
|
8
20
|
*
|
|
@@ -25,18 +37,23 @@ import { buildEntityRestAdapterMixins as adapterMixins } from '../build-entity-r
|
|
|
25
37
|
// envelope has to be built against the *constructor's* entity type, which a
|
|
26
38
|
// shadowed parameter would make unreachable.
|
|
27
39
|
(entity)=>Effect.gen(function*() {
|
|
28
|
-
var _metaEntity_key;
|
|
40
|
+
var _metaEntity_key, _pendingKeys_get;
|
|
29
41
|
const metaEntity = extractMetaEntity(entityConstructor);
|
|
30
42
|
const key = (_metaEntity_key = metaEntity.key) != null ? _metaEntity_key : metaEntity.name;
|
|
31
|
-
const isCreate = entity.id == null;
|
|
43
|
+
const isCreate = entity.id == null || pendingCreates.has(entity);
|
|
32
44
|
const url = yield* adapterMixins.buildEntityBaseUrl(configuration, {
|
|
33
45
|
uriConfig
|
|
34
46
|
}, key, isCreate ? undefined : String(entity.id));
|
|
35
47
|
if (isCreate && create === 'command') {
|
|
48
|
+
var _pendingKeys_get1;
|
|
36
49
|
// `crypto.randomUUID` is available in every browser this ships to and in
|
|
37
50
|
// Node — and the id must be a UUID, because the service constrains the
|
|
38
51
|
// key space a caller may address.
|
|
39
|
-
|
|
52
|
+
// A retried create reuses the id it was first sent with: that id *is*
|
|
53
|
+
// the idempotency key, so a new one would write a second record.
|
|
54
|
+
const transactionId = (_pendingKeys_get1 = pendingKeys.get(entity)) != null ? _pendingKeys_get1 : crypto.randomUUID();
|
|
55
|
+
pendingKeys.set(entity, transactionId);
|
|
56
|
+
pendingCreates.add(entity);
|
|
40
57
|
entity.id = transactionId;
|
|
41
58
|
const command = {
|
|
42
59
|
transactionId,
|
|
@@ -47,12 +64,15 @@ import { buildEntityRestAdapterMixins as adapterMixins } from '../build-entity-r
|
|
|
47
64
|
const accepted = yield* performHttpRequestThroughFetch(adapterMixins.buildEntityRequest({
|
|
48
65
|
method: 'POST',
|
|
49
66
|
url,
|
|
50
|
-
envelope: makeCommandEnvelope(command)
|
|
67
|
+
envelope: makeCommandEnvelope(command),
|
|
68
|
+
idempotencyKey: transactionId
|
|
51
69
|
}));
|
|
52
70
|
// Read for its shape, not its contents: this asserts the service really
|
|
53
71
|
// accepted a transaction rather than answering something else with a
|
|
54
72
|
// 2xx, which is the failure the old code path could not distinguish.
|
|
55
73
|
yield* readTransactionAcceptedEnvelope(accepted.body);
|
|
74
|
+
pendingKeys.delete(entity);
|
|
75
|
+
pendingCreates.delete(entity);
|
|
56
76
|
// Tell whoever is watching that a write is in flight. This is the only
|
|
57
77
|
// point in the system that knows it — the entity returned below is
|
|
58
78
|
// indistinguishable from a plain REST create, so without this the
|
|
@@ -77,13 +97,17 @@ import { buildEntityRestAdapterMixins as adapterMixins } from '../build-entity-r
|
|
|
77
97
|
}
|
|
78
98
|
return entity;
|
|
79
99
|
}
|
|
100
|
+
const idempotencyKey = (_pendingKeys_get = pendingKeys.get(entity)) != null ? _pendingKeys_get : crypto.randomUUID();
|
|
101
|
+
pendingKeys.set(entity, idempotencyKey);
|
|
80
102
|
const httpRequest = adapterMixins.buildEntityRequest({
|
|
81
103
|
method: isCreate ? 'POST' : 'PUT',
|
|
82
104
|
url,
|
|
83
|
-
envelope: makeEntityEnvelope(entityConstructor, entity)
|
|
105
|
+
envelope: makeEntityEnvelope(entityConstructor, entity),
|
|
106
|
+
idempotencyKey
|
|
84
107
|
});
|
|
85
108
|
const response = yield* performHttpRequestThroughFetch(httpRequest);
|
|
86
109
|
const saved = yield* readEntityEnvelope(entityConstructor, response.body);
|
|
110
|
+
pendingKeys.delete(entity);
|
|
87
111
|
return saved;
|
|
88
112
|
});
|
|
89
113
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../src/adapters/entity-rest/build-entity-rest-adapter-save/build-entity-rest-adapter-save.ts"],"sourcesContent":["import {\n Entity,\n EntityConstructor,\n extractMetaEntity,\n makeEntityEnvelope,\n readEntityEnvelope,\n serializeEntity,\n} from '@entifix/core';\nimport {\n makeCommandEnvelope,\n readTransactionAcceptedEnvelope,\n type TransactionCommand,\n TransactionSinkTag,\n} from '@entifix/transactions';\nimport { Effect, Option } from 'effect';\n\nimport { performHttpRequestThroughFetch } from '../../../clients/fetch/index.js';\nimport { buildEntityRestAdapterMixins as adapterMixins } from '../build-entity-rest-adapter-mixins/index.js';\nimport { BuildEntityRestOptions } from '../types.js';\n\n/**\n * Persists an entity over HTTP.\n *\n * An entity without an id has never been stored, so it is created with `POST`\n * against the collection; one with an id is replaced with `PUT` against its own\n * URL.\n *\n * For a plain create the response envelope — not the request — is what gets\n * deserialized and returned, because the service is the authority on the stored\n * entity (it assigns ids and may normalize fields).\n *\n * A **transactional** create inverts that, and deliberately. The service answers\n * `202` describing a transaction rather than an entity, so there is no stored\n * entity to read back yet: the client mints the transaction id, which *is* the\n * id the entity will be stored under, and returns the entity it already holds.\n * That is the point of the client owning the id — before it, this\n * adapter parsed the `202` as an entity envelope, failed, and reported an error\n * for a create that was in fact about to succeed.\n */\nexport const buildEntityRestAdapterSave =\n <TEntity extends Entity>(\n entityConstructor: EntityConstructor<TEntity>,\n { uriConfig, create = 'entity', configuration }: BuildEntityRestOptions,\n ) =>\n // Deliberately not shadowing `TEntity` here (as the read builders do): the\n // envelope has to be built against the *constructor's* entity type, which a\n // shadowed parameter would make unreachable.\n <TInput extends Entity>(entity: TInput) =>\n Effect.gen(function* () {\n const metaEntity = extractMetaEntity(entityConstructor);\n const key = metaEntity.key ?? metaEntity.name;\n const isCreate = entity.id == null;\n\n const url = yield* adapterMixins.buildEntityBaseUrl(\n configuration,\n { uriConfig },\n key,\n isCreate ? undefined : String(entity.id),\n );\n\n if (isCreate && create === 'command') {\n // `crypto.randomUUID` is available in every browser this ships to and in\n // Node — and the id must be a UUID, because the service constrains the\n // key space a caller may address.\n const transactionId = crypto.randomUUID();\n entity.id = transactionId;\n\n const command: TransactionCommand = {\n transactionId,\n type: 'create',\n entity: key,\n payload: serializeEntity(\n entityConstructor,\n entity as unknown as TEntity,\n ),\n };\n\n const accepted = yield* performHttpRequestThroughFetch(\n adapterMixins.buildEntityRequest({\n method: 'POST',\n url,\n envelope: makeCommandEnvelope(command),\n }),\n );\n // Read for its shape, not its contents: this asserts the service really\n // accepted a transaction rather than answering something else with a\n // 2xx, which is the failure the old code path could not distinguish.\n yield* readTransactionAcceptedEnvelope(accepted.body);\n\n // Tell whoever is watching that a write is in flight. This is the only\n // point in the system that knows it — the entity returned below is\n // indistinguishable from a plain REST create, so without this the\n // browser navigates away at the `202` and a later failure leaves no row,\n // no message and nothing to retry from.\n //\n // `serviceOption`, deliberately: reading the tag this way keeps it out\n // of this function's `R`, so the storefront, the plain REST adapters and\n // every existing spec compile and run with no layer to provide. The cost\n // is that nothing *forces* a composition root to provide it, which is\n // why `mergeContext` has a spec pinning that it does.\n const sink = yield* Effect.serviceOption(TransactionSinkTag);\n if (Option.isSome(sink)) {\n // After the assertion above, never before: a `2xx` that is not a\n // transaction envelope must not register a pending id, because nothing\n // would ever settle it and the entry becomes a permanent phantom.\n sink.value.began({\n transactionId,\n entity: key,\n at: new Date().toISOString(),\n });\n }\n\n return entity;\n }\n\n const httpRequest = adapterMixins.buildEntityRequest({\n method: isCreate ? 'POST' : 'PUT',\n url,\n envelope: makeEntityEnvelope(\n entityConstructor,\n entity as unknown as TEntity,\n ),\n });\n\n const response = yield* performHttpRequestThroughFetch(httpRequest);\n const saved = yield* readEntityEnvelope(entityConstructor, response.body);\n\n return saved as unknown as TInput;\n });\n"],"names":["extractMetaEntity","makeEntityEnvelope","readEntityEnvelope","serializeEntity","makeCommandEnvelope","readTransactionAcceptedEnvelope","TransactionSinkTag","Effect","Option","performHttpRequestThroughFetch","buildEntityRestAdapterMixins","adapterMixins","buildEntityRestAdapterSave","entityConstructor","uriConfig","create","configuration","entity","gen","metaEntity","key","name","isCreate","id","url","buildEntityBaseUrl","undefined","String","transactionId","crypto","randomUUID","command","type","payload","accepted","buildEntityRequest","method","envelope","body","sink","serviceOption","isSome","value","began","at","Date","toISOString","httpRequest","response","saved"],"mappings":"AAAA,SAGEA,iBAAiB,EACjBC,kBAAkB,EAClBC,kBAAkB,EAClBC,eAAe,QACV,gBAAgB;AACvB,SACEC,mBAAmB,EACnBC,+BAA+B,EAE/BC,kBAAkB,QACb,wBAAwB;AAC/B,SAASC,MAAM,EAAEC,MAAM,QAAQ,SAAS;AAExC,SAASC,8BAA8B,QAAQ,kCAAkC;AACjF,SAASC,gCAAgCC,aAAa,QAAQ,+CAA+C;AAG7G;;;;;;;;;;;;;;;;;;CAkBC,GACD,OAAO,MAAMC,6BACX,CACEC,mBACA,EAAEC,SAAS,EAAEC,SAAS,QAAQ,EAAEC,aAAa,EAA0B,GAEzE,2EAA2E;IAC3E,4EAA4E;IAC5E,6CAA6C;IAC7C,CAAwBC,
|
|
1
|
+
{"version":3,"sources":["../../../../src/adapters/entity-rest/build-entity-rest-adapter-save/build-entity-rest-adapter-save.ts"],"sourcesContent":["import {\n Entity,\n EntityConstructor,\n extractMetaEntity,\n makeEntityEnvelope,\n readEntityEnvelope,\n serializeEntity,\n} from '@entifix/core';\nimport {\n makeCommandEnvelope,\n readTransactionAcceptedEnvelope,\n type TransactionCommand,\n TransactionSinkTag,\n} from '@entifix/transactions';\nimport { Effect, Option } from 'effect';\n\nimport { performHttpRequestThroughFetch } from '../../../clients/fetch/index.js';\nimport { buildEntityRestAdapterMixins as adapterMixins } from '../build-entity-rest-adapter-mixins/index.js';\nimport { BuildEntityRestOptions } from '../types.js';\n\n/**\n * The `Idempotency-Key` each entity's pending save was sent under, kept until\n * that save succeeds.\n *\n * Keyed by the entity instance, so a save called again on the same object\n * after a network error — the case the key exists for — sends the same key and\n * the service replays its first answer instead of writing twice. A save that\n * succeeded forgets its key, so the next edit of that object is a new request.\n * The browser itself never retries (ADR 0014); this is what makes a person's\n * retry safe.\n */\nconst pendingKeys = new WeakMap<object, string>();\n\n/** A transactional create whose `202` never arrived: same command, same id. */\nconst pendingCreates = new WeakSet<object>();\n\n/**\n * Persists an entity over HTTP.\n *\n * An entity without an id has never been stored, so it is created with `POST`\n * against the collection; one with an id is replaced with `PUT` against its own\n * URL.\n *\n * For a plain create the response envelope — not the request — is what gets\n * deserialized and returned, because the service is the authority on the stored\n * entity (it assigns ids and may normalize fields).\n *\n * A **transactional** create inverts that, and deliberately. The service answers\n * `202` describing a transaction rather than an entity, so there is no stored\n * entity to read back yet: the client mints the transaction id, which *is* the\n * id the entity will be stored under, and returns the entity it already holds.\n * That is the point of the client owning the id — before it, this\n * adapter parsed the `202` as an entity envelope, failed, and reported an error\n * for a create that was in fact about to succeed.\n */\nexport const buildEntityRestAdapterSave =\n <TEntity extends Entity>(\n entityConstructor: EntityConstructor<TEntity>,\n { uriConfig, create = 'entity', configuration }: BuildEntityRestOptions,\n ) =>\n // Deliberately not shadowing `TEntity` here (as the read builders do): the\n // envelope has to be built against the *constructor's* entity type, which a\n // shadowed parameter would make unreachable.\n <TInput extends Entity>(entity: TInput) =>\n Effect.gen(function* () {\n const metaEntity = extractMetaEntity(entityConstructor);\n const key = metaEntity.key ?? metaEntity.name;\n const isCreate = entity.id == null || pendingCreates.has(entity);\n\n const url = yield* adapterMixins.buildEntityBaseUrl(\n configuration,\n { uriConfig },\n key,\n isCreate ? undefined : String(entity.id),\n );\n\n if (isCreate && create === 'command') {\n // `crypto.randomUUID` is available in every browser this ships to and in\n // Node — and the id must be a UUID, because the service constrains the\n // key space a caller may address.\n // A retried create reuses the id it was first sent with: that id *is*\n // the idempotency key, so a new one would write a second record.\n const transactionId = pendingKeys.get(entity) ?? crypto.randomUUID();\n pendingKeys.set(entity, transactionId);\n pendingCreates.add(entity);\n entity.id = transactionId;\n\n const command: TransactionCommand = {\n transactionId,\n type: 'create',\n entity: key,\n payload: serializeEntity(\n entityConstructor,\n entity as unknown as TEntity,\n ),\n };\n\n const accepted = yield* performHttpRequestThroughFetch(\n adapterMixins.buildEntityRequest({\n method: 'POST',\n url,\n envelope: makeCommandEnvelope(command),\n idempotencyKey: transactionId,\n }),\n );\n // Read for its shape, not its contents: this asserts the service really\n // accepted a transaction rather than answering something else with a\n // 2xx, which is the failure the old code path could not distinguish.\n yield* readTransactionAcceptedEnvelope(accepted.body);\n pendingKeys.delete(entity);\n pendingCreates.delete(entity);\n\n // Tell whoever is watching that a write is in flight. This is the only\n // point in the system that knows it — the entity returned below is\n // indistinguishable from a plain REST create, so without this the\n // browser navigates away at the `202` and a later failure leaves no row,\n // no message and nothing to retry from.\n //\n // `serviceOption`, deliberately: reading the tag this way keeps it out\n // of this function's `R`, so the storefront, the plain REST adapters and\n // every existing spec compile and run with no layer to provide. The cost\n // is that nothing *forces* a composition root to provide it, which is\n // why `mergeContext` has a spec pinning that it does.\n const sink = yield* Effect.serviceOption(TransactionSinkTag);\n if (Option.isSome(sink)) {\n // After the assertion above, never before: a `2xx` that is not a\n // transaction envelope must not register a pending id, because nothing\n // would ever settle it and the entry becomes a permanent phantom.\n sink.value.began({\n transactionId,\n entity: key,\n at: new Date().toISOString(),\n });\n }\n\n return entity;\n }\n\n const idempotencyKey = pendingKeys.get(entity) ?? crypto.randomUUID();\n pendingKeys.set(entity, idempotencyKey);\n const httpRequest = adapterMixins.buildEntityRequest({\n method: isCreate ? 'POST' : 'PUT',\n url,\n envelope: makeEntityEnvelope(\n entityConstructor,\n entity as unknown as TEntity,\n ),\n idempotencyKey,\n });\n\n const response = yield* performHttpRequestThroughFetch(httpRequest);\n const saved = yield* readEntityEnvelope(entityConstructor, response.body);\n pendingKeys.delete(entity);\n\n return saved as unknown as TInput;\n });\n"],"names":["extractMetaEntity","makeEntityEnvelope","readEntityEnvelope","serializeEntity","makeCommandEnvelope","readTransactionAcceptedEnvelope","TransactionSinkTag","Effect","Option","performHttpRequestThroughFetch","buildEntityRestAdapterMixins","adapterMixins","pendingKeys","WeakMap","pendingCreates","WeakSet","buildEntityRestAdapterSave","entityConstructor","uriConfig","create","configuration","entity","gen","metaEntity","key","name","isCreate","id","has","url","buildEntityBaseUrl","undefined","String","transactionId","get","crypto","randomUUID","set","add","command","type","payload","accepted","buildEntityRequest","method","envelope","idempotencyKey","body","delete","sink","serviceOption","isSome","value","began","at","Date","toISOString","httpRequest","response","saved"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@entifix/rest",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "HTTP entity adapter for the browser: get, load, save and delete against an entifix service, plus served metadata and transaction status.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -51,12 +51,12 @@
|
|
|
51
51
|
},
|
|
52
52
|
"dependencies": {
|
|
53
53
|
"@swc/helpers": "0.5.23",
|
|
54
|
-
"@entifix/
|
|
55
|
-
"@entifix/
|
|
54
|
+
"@entifix/core": "0.11.0",
|
|
55
|
+
"@entifix/transactions": "0.11.0"
|
|
56
56
|
},
|
|
57
57
|
"devDependencies": {
|
|
58
58
|
"effect": "4.0.6",
|
|
59
|
-
"@entifix/testing-unit": "0.
|
|
59
|
+
"@entifix/testing-unit": "0.11.0"
|
|
60
60
|
},
|
|
61
61
|
"peerDependencies": {
|
|
62
62
|
"effect": "^4.0.6"
|