@entifix/react-integration 0.1.4 → 0.2.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/reactive/event-source-channel.js +4 -4
- package/dist/reactive/event-source-channel.js.map +1 -1
- package/dist/reactive/reactive-channel.d.ts +1 -1
- package/dist/reactive/reactive-channel.d.ts.map +1 -1
- package/dist/reactive/reactive-channel.js +1 -1
- package/dist/reactive/reactive-channel.js.map +1 -1
- package/package.json +7 -7
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readEventEnvelope } from '@entifix/core';
|
|
2
|
-
import { Effect,
|
|
2
|
+
import { Effect, Result } from 'effect';
|
|
3
3
|
/**
|
|
4
4
|
* The reactive stream's browser end: one `EventSource` over a same-origin URL.
|
|
5
5
|
*
|
|
@@ -31,12 +31,12 @@ import { Effect, Either } from 'effect';
|
|
|
31
31
|
// A frame this build cannot read is dropped rather than thrown: `onmessage`
|
|
32
32
|
// runs inside the browser's event loop, where a throw reaches no caller and
|
|
33
33
|
// would take the rest of this delivery's listeners with it.
|
|
34
|
-
const parsed = Effect.runSync(Effect.
|
|
35
|
-
if (
|
|
34
|
+
const parsed = Effect.runSync(Effect.result(Effect.flatMap(Effect.try(()=>JSON.parse(raw)), readEventEnvelope)));
|
|
35
|
+
if (Result.isFailure(parsed)) {
|
|
36
36
|
return;
|
|
37
37
|
}
|
|
38
38
|
for (const listener of listeners){
|
|
39
|
-
listener(parsed.
|
|
39
|
+
listener(parsed.success);
|
|
40
40
|
}
|
|
41
41
|
};
|
|
42
42
|
const open = ()=>{
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/reactive/event-source-channel.ts"],"sourcesContent":["import { type EntityChangeEvent, readEventEnvelope } from '@entifix/core';\nimport { Effect,
|
|
1
|
+
{"version":3,"sources":["../../src/reactive/event-source-channel.ts"],"sourcesContent":["import { type EntityChangeEvent, readEventEnvelope } from '@entifix/core';\nimport { Effect, Result } from 'effect';\n\nimport type {\n EntityChangeListener,\n ReactiveChannel,\n} from './reactive-channel.js';\n\n/**\n * The reactive stream's browser end: one `EventSource` over a same-origin URL.\n *\n * **Same-origin is the whole reason this is SSE and not a WebSocket.** `entifix_at`\n * is `httpOnly` and `sameSite: 'lax'`, and the `WebSocket` constructor accepts\n * no headers — so a socket would need a second class of bearer token handed to\n * client JavaScript, which is the one thing `httpOnly` protects against. An\n * `EventSource` against the app's own proxy path sends the cookie the way every\n * other call in the repo does (ADR 0036).\n *\n * What it also gets for free is the half #137 would otherwise hand-write:\n * `EventSource` reconnects on its own with backoff and re-sends `Last-Event-ID`.\n * The server accepts that header and ignores it — the stream is a hint that\n * something changed, and the record behind it is the truth.\n *\n * The connection is opened lazily on the first subscriber and closed with the\n * last, so a page that mounts no workspace holds none. One connection per URL,\n * not one per listener: SSE shares the origin's HTTP/1.1 connection budget.\n */\nexport function makeEventSourceReactiveChannel(url: string): ReactiveChannel {\n const listeners = new Set<EntityChangeListener>();\n const connectListeners = new Set<() => void>();\n let source: EventSource | undefined;\n // Tracked here rather than read off `source.readyState`, because it must\n // survive the close/reopen cycle the refcount drives: what a reconciling\n // consumer needs to know is \"has this channel been connected\", not \"is this\n // particular `EventSource` object open right now\".\n let connected = false;\n\n const deliver = (raw: string): void => {\n // A frame this build cannot read is dropped rather than thrown: `onmessage`\n // runs inside the browser's event loop, where a throw reaches no caller and\n // would take the rest of this delivery's listeners with it.\n const parsed = Effect.runSync(\n Effect.result(\n Effect.flatMap(\n Effect.try(() => JSON.parse(raw) as unknown),\n readEventEnvelope<EntityChangeEvent>,\n ),\n ),\n );\n if (Result.isFailure(parsed)) {\n return;\n }\n\n for (const listener of listeners) {\n listener(parsed.success);\n }\n };\n\n const open = (): void => {\n // Guarded because a server-rendered pass has no `EventSource` at all, and a\n // channel built at module scope must not throw there.\n if (source !== undefined || typeof EventSource === 'undefined') {\n return;\n }\n source = new EventSource(url);\n source.onmessage = message => {\n deliver(message.data as string);\n };\n // Fires on the first open *and* on every browser-driven reconnect, because\n // `EventSource` reconnects the same object rather than handing back a new\n // one — so nothing has to re-arm this.\n source.onopen = () => {\n connected = true;\n for (const listener of connectListeners) {\n listener();\n }\n };\n };\n\n const close = (): void => {\n source?.close();\n source = undefined;\n connected = false;\n };\n\n return {\n subscribe(listener) {\n listeners.add(listener);\n open();\n return () => {\n listeners.delete(listener);\n if (listeners.size === 0) {\n close();\n }\n };\n },\n onConnect(listener) {\n connectListeners.add(listener);\n // ⚠️ The replay, and the reason this signal works at all. Registering does\n // *not* open the connection — subscribing is what does that — so a\n // consumer arriving after another one opened the stream would otherwise\n // wait for a network drop that may never come, and a restored pending set\n // would never be reconciled.\n if (connected) listener();\n return () => {\n connectListeners.delete(listener);\n };\n },\n };\n}\n"],"names":["readEventEnvelope","Effect","Result","makeEventSourceReactiveChannel","url","listeners","Set","connectListeners","source","connected","deliver","raw","parsed","runSync","result","flatMap","try","JSON","parse","isFailure","listener","success","open","undefined","EventSource","onmessage","message","data","onopen","close","subscribe","add","delete","size","onConnect"],"mappings":"AAAA,SAAiCA,iBAAiB,QAAQ,gBAAgB;AAC1E,SAASC,MAAM,EAAEC,MAAM,QAAQ,SAAS;AAOxC;;;;;;;;;;;;;;;;;;CAkBC,GACD,OAAO,SAASC,+BAA+BC,GAAW;IACxD,MAAMC,YAAY,IAAIC;IACtB,MAAMC,mBAAmB,IAAID;IAC7B,IAAIE;IACJ,yEAAyE;IACzE,yEAAyE;IACzE,4EAA4E;IAC5E,mDAAmD;IACnD,IAAIC,YAAY;IAEhB,MAAMC,UAAU,CAACC;QACf,4EAA4E;QAC5E,4EAA4E;QAC5E,4DAA4D;QAC5D,MAAMC,SAASX,OAAOY,OAAO,CAC3BZ,OAAOa,MAAM,CACXb,OAAOc,OAAO,CACZd,OAAOe,GAAG,CAAC,IAAMC,KAAKC,KAAK,CAACP,OAC5BX;QAIN,IAAIE,OAAOiB,SAAS,CAACP,SAAS;YAC5B;QACF;QAEA,KAAK,MAAMQ,YAAYf,UAAW;YAChCe,SAASR,OAAOS,OAAO;QACzB;IACF;IAEA,MAAMC,OAAO;QACX,4EAA4E;QAC5E,sDAAsD;QACtD,IAAId,WAAWe,aAAa,OAAOC,gBAAgB,aAAa;YAC9D;QACF;QACAhB,SAAS,IAAIgB,YAAYpB;QACzBI,OAAOiB,SAAS,GAAGC,CAAAA;YACjBhB,QAAQgB,QAAQC,IAAI;QACtB;QACA,2EAA2E;QAC3E,0EAA0E;QAC1E,uCAAuC;QACvCnB,OAAOoB,MAAM,GAAG;YACdnB,YAAY;YACZ,KAAK,MAAMW,YAAYb,iBAAkB;gBACvCa;YACF;QACF;IACF;IAEA,MAAMS,QAAQ;QACZrB,0BAAAA,OAAQqB,KAAK;QACbrB,SAASe;QACTd,YAAY;IACd;IAEA,OAAO;QACLqB,WAAUV,QAAQ;YAChBf,UAAU0B,GAAG,CAACX;YACdE;YACA,OAAO;gBACLjB,UAAU2B,MAAM,CAACZ;gBACjB,IAAIf,UAAU4B,IAAI,KAAK,GAAG;oBACxBJ;gBACF;YACF;QACF;QACAK,WAAUd,QAAQ;YAChBb,iBAAiBwB,GAAG,CAACX;YACrB,2EAA2E;YAC3E,mEAAmE;YACnE,wEAAwE;YACxE,0EAA0E;YAC1E,6BAA6B;YAC7B,IAAIX,WAAWW;YACf,OAAO;gBACLb,iBAAiByB,MAAM,CAACZ;YAC1B;QACF;IACF;AACF"}
|
|
@@ -40,7 +40,7 @@ export interface ReactiveChannel {
|
|
|
40
40
|
*/
|
|
41
41
|
onConnect(listener: () => void): () => void;
|
|
42
42
|
}
|
|
43
|
-
declare const ReactiveChannelTag_base: Context.
|
|
43
|
+
declare const ReactiveChannelTag_base: Context.ServiceClass<ReactiveChannelTag, "ReactiveChannel", ReactiveChannel>;
|
|
44
44
|
export declare class ReactiveChannelTag extends ReactiveChannelTag_base {
|
|
45
45
|
}
|
|
46
46
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"reactive-channel.d.ts","sourceRoot":"","sources":["../../src/reactive/reactive-channel.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAC;AACpE,OAAO,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAEjC,YAAY,EAAE,iBAAiB,EAAE,CAAC;AAElC;;;;;;;;GAQG;AACH,MAAM,MAAM,oBAAoB,GAAG,CACjC,KAAK,EAAE,WAAW,CAAC,iBAAiB,CAAC,KAClC,IAAI,CAAC;AAEV;;;;GAIG;AACH,MAAM,WAAW,eAAe;IAC9B,SAAS,CAAC,QAAQ,EAAE,oBAAoB,GAAG,MAAM,IAAI,CAAC;IACtD;;;;;;;;;;;;;;;;;;;OAmBG;IACH,SAAS,CAAC,QAAQ,EAAE,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC;CAC7C;;AAED,qBAAa,kBAAmB,SAAQ,
|
|
1
|
+
{"version":3,"file":"reactive-channel.d.ts","sourceRoot":"","sources":["../../src/reactive/reactive-channel.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAC;AACpE,OAAO,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAEjC,YAAY,EAAE,iBAAiB,EAAE,CAAC;AAElC;;;;;;;;GAQG;AACH,MAAM,MAAM,oBAAoB,GAAG,CACjC,KAAK,EAAE,WAAW,CAAC,iBAAiB,CAAC,KAClC,IAAI,CAAC;AAEV;;;;GAIG;AACH,MAAM,WAAW,eAAe;IAC9B,SAAS,CAAC,QAAQ,EAAE,oBAAoB,GAAG,MAAM,IAAI,CAAC;IACtD;;;;;;;;;;;;;;;;;;;OAmBG;IACH,SAAS,CAAC,QAAQ,EAAE,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC;CAC7C;;AAED,qBAAa,kBAAmB,SAAQ,uBAGlB;CAAG;AAEzB;;;;;;GAMG;AACH,eAAO,MAAM,mBAAmB,EAAE,eAGjC,CAAC;AAEF,MAAM,WAAW,uBAAwB,SAAQ,eAAe;IAC9D,6EAA6E;IAC7E,IAAI,CAAC,KAAK,EAAE,WAAW,CAAC,iBAAiB,CAAC,GAAG,IAAI,CAAC;IAClD;;;;;;OAMG;IACH,OAAO,IAAI,IAAI,CAAC;CACjB;AAED;;;;;;;GAOG;AACH,wBAAgB,2BAA2B,IAAI,uBAAuB,CAgCrE"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Context } from 'effect';
|
|
2
|
-
export class ReactiveChannelTag extends Context.
|
|
2
|
+
export class ReactiveChannelTag extends Context.Service()('ReactiveChannel') {
|
|
3
3
|
}
|
|
4
4
|
/**
|
|
5
5
|
* A channel that never emits — the default until a transport is provided.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/reactive/reactive-channel.ts"],"sourcesContent":["import type { DomainEvent, EntityChangeEvent } from '@entifix/core';\nimport { Context } from 'effect';\n\nexport type { EntityChangeEvent };\n\n/**\n * A listener on the reactive stream.\n *\n * It receives the whole message, not just the payload: the transaction id, the\n * emission time and the sequence a consumer needs are `meta.event.correlationId`,\n * `.at` and `.id`, and duplicating them onto {@link EntityChangeEvent} is how\n * two sources of one fact drift (ADR 0036). `#137` settles an optimistic write\n * on the correlation id; `useReactiveInvalidation` reads only `data.entity`.\n */\nexport type EntityChangeListener = (\n event: DomainEvent<EntityChangeEvent>,\n) => void;\n\n/**\n * The reactive-updates port. Framework-free on purpose (like the OTel tooling):\n * the transport is swappable, and the React side wires `subscribe` to the query\n * client. `subscribe` returns its own unsubscribe.\n */\nexport interface ReactiveChannel {\n subscribe(listener: EntityChangeListener): () => void;\n /**\n * Runs whenever the stream is (re)connected — and **immediately if it is\n * already connected when the listener registers**.\n *\n * The stream does not replay, by design (ADR 0036), so a consumer that missed\n * an outcome while disconnected has to go and ask. This is the signal that\n * tells it when to: `#137` re-queries every pending transaction id here.\n *\n * ⚠️ **The replay is the mechanism, not a convenience.** The channel is\n * refcounted and opened by its *first* subscriber, and\n * `useReactiveInvalidation` typically is that subscriber. A consumer mounting\n * after it — which depends on nothing more than component-tree ordering —\n * would otherwise register for an open that had already happened, and\n * reconcile nothing until the next real network drop. On a page load with no\n * disconnect, a pending set restored from the previous session would never be\n * reconciled at all, which is the case this exists for.\n *\n * Required rather than optional: an optional member on a port is how one\n * implementation quietly stops honouring it while every consumer compiles.\n */\n onConnect(listener: () => void): () => void;\n}\n\nexport class ReactiveChannelTag extends Context.
|
|
1
|
+
{"version":3,"sources":["../../src/reactive/reactive-channel.ts"],"sourcesContent":["import type { DomainEvent, EntityChangeEvent } from '@entifix/core';\nimport { Context } from 'effect';\n\nexport type { EntityChangeEvent };\n\n/**\n * A listener on the reactive stream.\n *\n * It receives the whole message, not just the payload: the transaction id, the\n * emission time and the sequence a consumer needs are `meta.event.correlationId`,\n * `.at` and `.id`, and duplicating them onto {@link EntityChangeEvent} is how\n * two sources of one fact drift (ADR 0036). `#137` settles an optimistic write\n * on the correlation id; `useReactiveInvalidation` reads only `data.entity`.\n */\nexport type EntityChangeListener = (\n event: DomainEvent<EntityChangeEvent>,\n) => void;\n\n/**\n * The reactive-updates port. Framework-free on purpose (like the OTel tooling):\n * the transport is swappable, and the React side wires `subscribe` to the query\n * client. `subscribe` returns its own unsubscribe.\n */\nexport interface ReactiveChannel {\n subscribe(listener: EntityChangeListener): () => void;\n /**\n * Runs whenever the stream is (re)connected — and **immediately if it is\n * already connected when the listener registers**.\n *\n * The stream does not replay, by design (ADR 0036), so a consumer that missed\n * an outcome while disconnected has to go and ask. This is the signal that\n * tells it when to: `#137` re-queries every pending transaction id here.\n *\n * ⚠️ **The replay is the mechanism, not a convenience.** The channel is\n * refcounted and opened by its *first* subscriber, and\n * `useReactiveInvalidation` typically is that subscriber. A consumer mounting\n * after it — which depends on nothing more than component-tree ordering —\n * would otherwise register for an open that had already happened, and\n * reconcile nothing until the next real network drop. On a page load with no\n * disconnect, a pending set restored from the previous session would never be\n * reconciled at all, which is the case this exists for.\n *\n * Required rather than optional: an optional member on a port is how one\n * implementation quietly stops honouring it while every consumer compiles.\n */\n onConnect(listener: () => void): () => void;\n}\n\nexport class ReactiveChannelTag extends Context.Service<\n ReactiveChannelTag,\n ReactiveChannel\n>()('ReactiveChannel') {}\n\n/**\n * A channel that never emits — the default until a transport is provided.\n *\n * It never connects either, so `onConnect` registers nothing: a consumer of this\n * channel reconciles never, which is the honest behaviour for a transport that\n * is not there.\n */\nexport const NoopReactiveChannel: ReactiveChannel = {\n subscribe: () => () => undefined,\n onConnect: () => () => undefined,\n};\n\nexport interface InMemoryReactiveChannel extends ReactiveChannel {\n /** Push an event to every current subscriber (drives tests and the mock). */\n emit(event: DomainEvent<EntityChangeEvent>): void;\n /**\n * Announce a (re)connection, so a spec can drive the reconciliation path.\n *\n * Also flips this channel to \"connected\", which is what makes a listener\n * registered afterwards fire immediately — the behaviour the real transport\n * has and the one most likely to be got wrong.\n */\n connect(): void;\n}\n\n/**\n * A synchronous in-memory channel: `emit` fans out to every live subscriber.\n *\n * It backs specs. It is no longer what the workspace runs on — that is\n * `makeEventSourceReactiveChannel`, and until ADR 0036 landed a transport this\n * one was mounted in its place, emitting nothing, so a reader of the workspace\n * could not tell \"the transport is missing\" from \"nothing changed\".\n */\nexport function makeInMemoryReactiveChannel(): InMemoryReactiveChannel {\n const listeners = new Set<EntityChangeListener>();\n const connectListeners = new Set<() => void>();\n let connected = false;\n return {\n subscribe(listener) {\n listeners.add(listener);\n return () => {\n listeners.delete(listener);\n };\n },\n onConnect(listener) {\n connectListeners.add(listener);\n // Mirrors the real transport: a listener arriving after the connection is\n // already up must not wait for the next drop to hear about it.\n if (connected) listener();\n return () => {\n connectListeners.delete(listener);\n };\n },\n connect() {\n connected = true;\n for (const listener of connectListeners) {\n listener();\n }\n },\n emit(event) {\n for (const listener of listeners) {\n listener(event);\n }\n },\n };\n}\n"],"names":["Context","ReactiveChannelTag","Service","NoopReactiveChannel","subscribe","undefined","onConnect","makeInMemoryReactiveChannel","listeners","Set","connectListeners","connected","listener","add","delete","connect","emit","event"],"mappings":"AACA,SAASA,OAAO,QAAQ,SAAS;AA+CjC,OAAO,MAAMC,2BAA2BD,QAAQE,OAAO,GAGnD;AAAoB;AAExB;;;;;;CAMC,GACD,OAAO,MAAMC,sBAAuC;IAClDC,WAAW,IAAM,IAAMC;IACvBC,WAAW,IAAM,IAAMD;AACzB,EAAE;AAeF;;;;;;;CAOC,GACD,OAAO,SAASE;IACd,MAAMC,YAAY,IAAIC;IACtB,MAAMC,mBAAmB,IAAID;IAC7B,IAAIE,YAAY;IAChB,OAAO;QACLP,WAAUQ,QAAQ;YAChBJ,UAAUK,GAAG,CAACD;YACd,OAAO;gBACLJ,UAAUM,MAAM,CAACF;YACnB;QACF;QACAN,WAAUM,QAAQ;YAChBF,iBAAiBG,GAAG,CAACD;YACrB,0EAA0E;YAC1E,+DAA+D;YAC/D,IAAID,WAAWC;YACf,OAAO;gBACLF,iBAAiBI,MAAM,CAACF;YAC1B;QACF;QACAG;YACEJ,YAAY;YACZ,KAAK,MAAMC,YAAYF,iBAAkB;gBACvCE;YACF;QACF;QACAI,MAAKC,KAAK;YACR,KAAK,MAAML,YAAYJ,UAAW;gBAChCI,SAASK;YACX;QACF;IACF;AACF"}
|
package/package.json
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
}
|
|
22
22
|
}
|
|
23
23
|
},
|
|
24
|
-
"version": "0.
|
|
24
|
+
"version": "0.2.0",
|
|
25
25
|
"description": "React hooks that bind entities to data: TanStack Query and Form over the entity adapters, drafts, mutations and live invalidation.",
|
|
26
26
|
"license": "MIT",
|
|
27
27
|
"repository": {
|
|
@@ -57,21 +57,21 @@
|
|
|
57
57
|
"LICENSE"
|
|
58
58
|
],
|
|
59
59
|
"dependencies": {
|
|
60
|
-
"@entifix/transactions": "0.
|
|
61
|
-
"@entifix/business": "0.
|
|
62
|
-
"@entifix/core": "0.
|
|
60
|
+
"@entifix/transactions": "0.2.0",
|
|
61
|
+
"@entifix/business": "0.2.0",
|
|
62
|
+
"@entifix/core": "0.2.0"
|
|
63
63
|
},
|
|
64
64
|
"devDependencies": {
|
|
65
65
|
"@tanstack/react-form": "1.33.5",
|
|
66
66
|
"@tanstack/react-query": "^5.101.4",
|
|
67
|
-
"effect": "
|
|
67
|
+
"effect": "4.0.5",
|
|
68
68
|
"react": "19.2.8",
|
|
69
|
-
"@entifix/testing-unit": "0.
|
|
69
|
+
"@entifix/testing-unit": "0.2.0"
|
|
70
70
|
},
|
|
71
71
|
"peerDependencies": {
|
|
72
72
|
"@tanstack/react-form": ">=1.33 <2",
|
|
73
73
|
"@tanstack/react-query": ">=5.101 <6",
|
|
74
|
-
"effect": "
|
|
74
|
+
"effect": "^4.0.5",
|
|
75
75
|
"react": ">=19 <20"
|
|
76
76
|
}
|
|
77
77
|
}
|