@carno.js/live 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (199) hide show
  1. package/LICENSE +21 -0
  2. package/dist/LiveEngine.d.ts +89 -0
  3. package/dist/LiveEngine.js +520 -0
  4. package/dist/LivePlugin.d.ts +55 -0
  5. package/dist/LivePlugin.js +165 -0
  6. package/dist/LiveService.d.ts +32 -0
  7. package/dist/LiveService.js +51 -0
  8. package/dist/auth/authorizer.d.ts +33 -0
  9. package/dist/auth/authorizer.js +32 -0
  10. package/dist/bus/InProcessBus.d.ts +8 -0
  11. package/dist/bus/InProcessBus.js +29 -0
  12. package/dist/bus/InvalidationBus.d.ts +15 -0
  13. package/dist/bus/InvalidationBus.js +2 -0
  14. package/dist/bus/PgNotifyBus.d.ts +55 -0
  15. package/dist/bus/PgNotifyBus.js +138 -0
  16. package/dist/client/angular.d.ts +19 -0
  17. package/dist/client/angular.js +71 -0
  18. package/dist/client/core.d.ts +111 -0
  19. package/dist/client/core.js +337 -0
  20. package/dist/client/hydrate.d.ts +19 -0
  21. package/dist/client/hydrate.js +65 -0
  22. package/dist/client/optimistic.d.ts +17 -0
  23. package/dist/client/optimistic.js +2 -0
  24. package/dist/client/react.d.ts +22 -0
  25. package/dist/client/react.js +69 -0
  26. package/dist/client/transport.d.ts +133 -0
  27. package/dist/client/transport.js +431 -0
  28. package/dist/client/vanilla.d.ts +54 -0
  29. package/dist/client/vanilla.js +111 -0
  30. package/dist/client/vue.d.ts +12 -0
  31. package/dist/client/vue.js +35 -0
  32. package/dist/config.d.ts +32 -0
  33. package/dist/config.js +21 -0
  34. package/dist/decorators/Live.d.ts +4 -0
  35. package/dist/decorators/Live.js +17 -0
  36. package/dist/emitters/AppEmitter.d.ts +20 -0
  37. package/dist/emitters/AppEmitter.js +59 -0
  38. package/dist/emitters/pg-listener.d.ts +50 -0
  39. package/dist/emitters/pg-listener.js +120 -0
  40. package/dist/emitters/pg-notify-emitter.d.ts +59 -0
  41. package/dist/emitters/pg-notify-emitter.js +105 -0
  42. package/dist/emitters/pg-trigger-sql.d.ts +27 -0
  43. package/dist/emitters/pg-trigger-sql.js +107 -0
  44. package/dist/emitters/statement-keys.d.ts +16 -0
  45. package/dist/emitters/statement-keys.js +182 -0
  46. package/dist/graph/DependencyGraph.d.ts +27 -0
  47. package/dist/graph/DependencyGraph.js +118 -0
  48. package/dist/graph/SubscriptionRegistry.d.ts +22 -0
  49. package/dist/graph/SubscriptionRegistry.js +92 -0
  50. package/dist/graph/dep-key.d.ts +14 -0
  51. package/dist/graph/dep-key.js +37 -0
  52. package/dist/graph/types.d.ts +13 -0
  53. package/dist/graph/types.js +2 -0
  54. package/dist/http/etag.d.ts +41 -0
  55. package/dist/http/etag.js +109 -0
  56. package/dist/index.d.ts +56 -0
  57. package/dist/index.js +111 -0
  58. package/dist/metadata.d.ts +16 -0
  59. package/dist/metadata.js +4 -0
  60. package/dist/observability.d.ts +25 -0
  61. package/dist/observability.js +44 -0
  62. package/dist/patch/PatchEngine.d.ts +20 -0
  63. package/dist/patch/PatchEngine.js +180 -0
  64. package/dist/patch/types.d.ts +33 -0
  65. package/dist/patch/types.js +2 -0
  66. package/dist/resource/ResourceRegistry.d.ts +32 -0
  67. package/dist/resource/ResourceRegistry.js +126 -0
  68. package/dist/resource/dependency-context.d.ts +25 -0
  69. package/dist/resource/dependency-context.js +45 -0
  70. package/dist/resource/instance-id.d.ts +17 -0
  71. package/dist/resource/instance-id.js +61 -0
  72. package/dist/resource/prefetch.d.ts +20 -0
  73. package/dist/resource/prefetch.js +31 -0
  74. package/dist/resource/route-executor.d.ts +13 -0
  75. package/dist/resource/route-executor.js +86 -0
  76. package/dist/resource/types.d.ts +26 -0
  77. package/dist/resource/types.js +2 -0
  78. package/dist/runtime.d.ts +41 -0
  79. package/dist/runtime.js +47 -0
  80. package/dist/shared/canonical.d.ts +14 -0
  81. package/dist/shared/canonical.js +60 -0
  82. package/dist/shared/descriptor.d.ts +44 -0
  83. package/dist/shared/descriptor.js +19 -0
  84. package/dist/shared/hash.d.ts +12 -0
  85. package/dist/shared/hash.js +29 -0
  86. package/dist/shared/inputs.d.ts +19 -0
  87. package/dist/shared/inputs.js +2 -0
  88. package/dist/shared/protocol.d.ts +83 -0
  89. package/dist/shared/protocol.js +9 -0
  90. package/dist/transport/FanTransport.d.ts +18 -0
  91. package/dist/transport/FanTransport.js +29 -0
  92. package/dist/transport/LiveGateway.d.ts +16 -0
  93. package/dist/transport/LiveGateway.js +126 -0
  94. package/dist/transport/SocketTransport.d.ts +18 -0
  95. package/dist/transport/SocketTransport.js +40 -0
  96. package/dist/transport/SseTransport.d.ts +30 -0
  97. package/dist/transport/SseTransport.js +92 -0
  98. package/dist/transport/scope-resolver.d.ts +20 -0
  99. package/dist/transport/scope-resolver.js +17 -0
  100. package/dist/transport/sse-routes.d.ts +24 -0
  101. package/dist/transport/sse-routes.js +78 -0
  102. package/package.json +92 -0
  103. package/src/LiveEngine.ts +730 -0
  104. package/src/LivePlugin.ts +253 -0
  105. package/src/LiveService.ts +47 -0
  106. package/src/auth/authorizer.ts +56 -0
  107. package/src/bus/InProcessBus.ts +28 -0
  108. package/src/bus/InvalidationBus.ts +17 -0
  109. package/src/bus/PgNotifyBus.ts +188 -0
  110. package/src/client/angular.ts +115 -0
  111. package/src/client/core.ts +492 -0
  112. package/src/client/hydrate.ts +80 -0
  113. package/src/client/optimistic.ts +19 -0
  114. package/src/client/react.ts +113 -0
  115. package/src/client/transport.ts +568 -0
  116. package/src/client/vanilla.ts +165 -0
  117. package/src/client/vue.ts +67 -0
  118. package/src/config.ts +49 -0
  119. package/src/decorators/Live.ts +16 -0
  120. package/src/emitters/AppEmitter.ts +69 -0
  121. package/src/emitters/pg-listener.ts +171 -0
  122. package/src/emitters/pg-notify-emitter.ts +143 -0
  123. package/src/emitters/pg-trigger-sql.ts +111 -0
  124. package/src/emitters/statement-keys.ts +229 -0
  125. package/src/graph/DependencyGraph.ts +147 -0
  126. package/src/graph/SubscriptionRegistry.ts +109 -0
  127. package/src/graph/dep-key.ts +41 -0
  128. package/src/graph/types.ts +15 -0
  129. package/src/http/etag.ts +144 -0
  130. package/src/index.ts +81 -0
  131. package/src/metadata.ts +21 -0
  132. package/src/observability.ts +54 -0
  133. package/src/patch/PatchEngine.ts +235 -0
  134. package/src/patch/types.ts +39 -0
  135. package/src/resource/ResourceRegistry.ts +178 -0
  136. package/src/resource/dependency-context.ts +48 -0
  137. package/src/resource/instance-id.ts +65 -0
  138. package/src/resource/prefetch.ts +49 -0
  139. package/src/resource/route-executor.ts +134 -0
  140. package/src/resource/types.ts +35 -0
  141. package/src/runtime.ts +69 -0
  142. package/src/shared/canonical.ts +63 -0
  143. package/src/shared/descriptor.ts +44 -0
  144. package/src/shared/hash.ts +31 -0
  145. package/src/shared/inputs.ts +20 -0
  146. package/src/shared/protocol.ts +96 -0
  147. package/src/transport/FanTransport.ts +34 -0
  148. package/src/transport/LiveGateway.ts +124 -0
  149. package/src/transport/SocketTransport.ts +43 -0
  150. package/src/transport/SseTransport.ts +112 -0
  151. package/src/transport/scope-resolver.ts +25 -0
  152. package/src/transport/sse-routes.ts +94 -0
  153. package/test/acceptance-fase-2.test.ts +248 -0
  154. package/test/acceptance-fase-3.test.ts +216 -0
  155. package/test/acceptance.test.ts +306 -0
  156. package/test/angular-adapter.test.ts +151 -0
  157. package/test/app-emitter.test.ts +112 -0
  158. package/test/authorization.test.ts +175 -0
  159. package/test/client-core.test.ts +287 -0
  160. package/test/client-transport.test.ts +154 -0
  161. package/test/dependency-graph.test.ts +83 -0
  162. package/test/etag.test.ts +193 -0
  163. package/test/fan-transport.test.ts +62 -0
  164. package/test/happydom.ts +24 -0
  165. package/test/inputs-body.test.ts +81 -0
  166. package/test/instance-id.test.ts +104 -0
  167. package/test/live-engine.test.ts +593 -0
  168. package/test/live-post.test.ts +81 -0
  169. package/test/metrics.test.ts +131 -0
  170. package/test/optimistic.test.ts +166 -0
  171. package/test/orm-integration.test.ts +75 -0
  172. package/test/patch-engine.test.ts +121 -0
  173. package/test/pg-bus.test.ts +162 -0
  174. package/test/pg-listen-probe.test.ts +56 -0
  175. package/test/pg-listener.test.ts +134 -0
  176. package/test/pg-notify-emitter.test.ts +113 -0
  177. package/test/pg-notify-integration.test.ts +88 -0
  178. package/test/polling-auth.test.ts +169 -0
  179. package/test/prefetch.test.ts +137 -0
  180. package/test/react-adapter.test.tsx +83 -0
  181. package/test/react-rerender.test.tsx +131 -0
  182. package/test/resource-registry-helper.ts +35 -0
  183. package/test/resource-registry.test.ts +144 -0
  184. package/test/route-pipeline.test.ts +139 -0
  185. package/test/shared.test.ts +53 -0
  186. package/test/sse-routes.test.ts +204 -0
  187. package/test/sse-transport.test.ts +91 -0
  188. package/test/statement-keys.test.ts +126 -0
  189. package/test/subscription-registry.test.ts +61 -0
  190. package/test/transport-ladder.test.ts +293 -0
  191. package/test/transport.test.ts +274 -0
  192. package/test/types/optimistic-types.ts +34 -0
  193. package/test/use-live-action.test.ts +127 -0
  194. package/test/use-live.test.ts +109 -0
  195. package/test/vanilla-adapter.test.ts +200 -0
  196. package/test/vue-adapter.test.ts +122 -0
  197. package/tsconfig.json +15 -0
  198. package/tsconfig.tsbuildinfo +1 -0
  199. package/tsconfig.types.json +10 -0
@@ -0,0 +1,144 @@
1
+ import type { CarnoClosure, CarnoMiddleware, Context } from '@carno.js/core';
2
+ import { canonical } from '../shared/canonical';
3
+ import { fnv1a64 } from '../shared/hash';
4
+ import { LIVE_POLL_HEADER, LIVE_RESOURCE_HEADER } from '../shared/protocol';
5
+ import type { LiveInputs, LiveScope } from '../resource/types';
6
+
7
+ export interface LiveRoutePath {
8
+ method: string;
9
+ path: string;
10
+ resourceId?: string;
11
+ }
12
+
13
+ export interface LivePollingRequest {
14
+ resourceId: string;
15
+ inputs: LiveInputs;
16
+ request: Request;
17
+ }
18
+
19
+ export type LivePollingGuard =
20
+ (request: LivePollingRequest) => LiveScope | null | Promise<LiveScope | null>;
21
+
22
+ /** `/cards/:id` matches `/cards/42` and nothing deeper. */
23
+ export function pathMatcher(pattern: string): RegExp {
24
+ const source = pattern
25
+ .replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
26
+ .replace(/:[A-Za-z_][A-Za-z0-9_]*/g, '[^/]+');
27
+
28
+ return new RegExp(`^${source}$`);
29
+ }
30
+
31
+ /**
32
+ * Content-hash `ETag` on live GET routes, and `304` when the client already
33
+ * holds that content.
34
+ *
35
+ * This is the bottom rung of §8.4: a client with neither WebSocket nor SSE
36
+ * polls the same route the SPA calls, and pays for a body only when something
37
+ * changed. The hash is the canonical one -- the same function the engine uses
38
+ * to decide whether a recompute produced a patch -- so reordered JSON keys are
39
+ * the same content, not a change.
40
+ *
41
+ * Scoped to live routes on purpose. Putting an `ETag` on every GET in the
42
+ * application would change the behaviour of routes that asked for none of this.
43
+ */
44
+ export class LiveETagMiddleware implements CarnoMiddleware {
45
+ private matchers: { matcher: RegExp; resourceId?: string }[] = [];
46
+ private pollingGuard: LivePollingGuard | null = null;
47
+
48
+ constructor(paths: LiveRoutePath[], private readonly options: { enabled?: boolean } = {}) {
49
+ this.setPaths(paths);
50
+ }
51
+
52
+ /** The plugin knows the live routes only after bootstrap. */
53
+ setPaths(paths: LiveRoutePath[]): void {
54
+ this.matchers = paths
55
+ .filter(entry => entry.method.toUpperCase() === 'GET')
56
+ .map(entry => ({ matcher: pathMatcher(entry.path), resourceId: entry.resourceId }));
57
+ }
58
+
59
+ setPollingGuard(guard: LivePollingGuard): void {
60
+ this.pollingGuard = guard;
61
+ }
62
+
63
+ async handle(ctx: Context, next: CarnoClosure): Promise<Response | void> {
64
+ const polling = ctx.req.headers.get(LIVE_POLL_HEADER) === '1';
65
+ const requestedResource = polling
66
+ ? ctx.req.headers.get(LIVE_RESOURCE_HEADER) ?? undefined
67
+ : undefined;
68
+ const route = ctx.method.toUpperCase() === 'GET'
69
+ ? this.routeFor(ctx.path, requestedResource)
70
+ : undefined;
71
+
72
+ if (polling && (!requestedResource || !route)) {
73
+ return new Response('Forbidden', { status: 403 });
74
+ }
75
+
76
+ if (!route) {
77
+ return next();
78
+ }
79
+
80
+ if (ctx.req.headers.get(LIVE_POLL_HEADER) === '1') {
81
+ const resourceId = route.resourceId;
82
+ const scope = resourceId && this.pollingGuard
83
+ ? await this.pollingGuard({
84
+ resourceId,
85
+ inputs: { params: ctx.params, query: ctx.query },
86
+ request: ctx.req
87
+ })
88
+ : null;
89
+
90
+ if (!scope) {
91
+ return new Response('Forbidden', { status: 403 });
92
+ }
93
+
94
+ if (scope.headers) {
95
+ const headers = new Headers(ctx.req.headers);
96
+ new Headers(scope.headers).forEach((value, key) => headers.set(key, value));
97
+ (ctx as { req: Request }).req = new Request(ctx.req, { headers });
98
+ }
99
+ }
100
+
101
+ if (this.options.enabled === false) {
102
+ return next();
103
+ }
104
+
105
+ const response = await next();
106
+
107
+ if (response.status !== 200) {
108
+ return response;
109
+ }
110
+
111
+ const contentType = response.headers.get('Content-Type') ?? '';
112
+
113
+ if (!contentType.includes('application/json')) {
114
+ return response;
115
+ }
116
+
117
+ // Reading the body consumes the stream, so everything below hands the
118
+ // caller a rebuilt response rather than the one it just drained.
119
+ const body = await response.clone().text();
120
+ let tag: string;
121
+
122
+ try {
123
+ tag = `"${fnv1a64(canonical(JSON.parse(body)))}"`;
124
+ } catch {
125
+ // Content-Type said JSON and it is not. Not our problem to fix.
126
+ return response;
127
+ }
128
+
129
+ if (ctx.req.headers.get('If-None-Match') === tag) {
130
+ return new Response(null, { status: 304, headers: { ETag: tag } });
131
+ }
132
+
133
+ const headers = new Headers(response.headers);
134
+ headers.set('ETag', tag);
135
+
136
+ return new Response(body, { status: 200, headers });
137
+ }
138
+
139
+ private routeFor(path: string, resourceId?: string): { resourceId?: string } | undefined {
140
+ return this.matchers.find(route =>
141
+ route.matcher.test(path) && (resourceId === undefined || route.resourceId === resourceId)
142
+ );
143
+ }
144
+ }
package/src/index.ts ADDED
@@ -0,0 +1,81 @@
1
+ import 'reflect-metadata';
2
+
3
+ // Decorator and metadata
4
+ export { Live } from './decorators/Live';
5
+ export { LIVE_META } from './metadata';
6
+ export type { LiveMeta, LiveOptions, LiveShared } from './metadata';
7
+
8
+ // Plugin and services
9
+ export { LivePlugin } from './LivePlugin';
10
+ export type { LivePluginOptions } from './LivePlugin';
11
+ export { closeLiveRuntime } from './runtime';
12
+ export { LiveService } from './LiveService';
13
+ export { LiveEngine } from './LiveEngine';
14
+ export type { LiveTransport, LiveStats } from './LiveEngine';
15
+
16
+ // Metrics
17
+ export { LiveMetrics } from './observability';
18
+ export type { MetricSink } from './observability';
19
+
20
+ // Configuration
21
+ export { DEFAULT_LIVE_CONFIG, resolveLiveConfig } from './config';
22
+ export type { LiveConfig } from './config';
23
+
24
+ // Scope
25
+ export { ConnectionScopeResolver } from './transport/scope-resolver';
26
+ export type { LiveHandshake, LiveScopeResolver } from './transport/scope-resolver';
27
+ export type { LiveExecutionContext, LiveInputs, LiveResourceExecutor, LiveScope } from './resource/types';
28
+
29
+ // Authorization
30
+ export { AllowAllAuthorizer, authKeysOf, isAuthKey } from './auth/authorizer';
31
+ export type { LiveAuthorizationRequest, LiveAuthorizer } from './auth/authorizer';
32
+
33
+ // Invalidation
34
+ export { InProcessBus } from './bus/InProcessBus';
35
+ export type { InvalidationBus, InvalidationHandler } from './bus/InvalidationBus';
36
+ export type { Dependency, InvalidationEvent } from './graph/types';
37
+ export { ancestorsOf, rowKey, tableKey } from './graph/dep-key';
38
+ export type { DepKey } from './graph/dep-key';
39
+ export { WriteDuringComputeError } from './emitters/AppEmitter';
40
+ export { PgNotifyBus, chunkEvents } from './bus/PgNotifyBus';
41
+ export type { PgNotifyBusOptions } from './bus/PgNotifyBus';
42
+ export { PgNotifyEmitter, eventsFromPayload } from './emitters/pg-notify-emitter';
43
+ export type { PgNotifyEmitterOptions, PgNotifyTable } from './emitters/pg-notify-emitter';
44
+ export { PgListener } from './emitters/pg-listener';
45
+ export type { ListenableSql, PgListenerOptions } from './emitters/pg-listener';
46
+ export { tableOfKey } from './graph/dep-key';
47
+
48
+ // Protocol and patches, shared with the client
49
+ export * from './shared/protocol';
50
+ export type { PatchOp, PathSegment } from './patch/types';
51
+ export { PatchEngine } from './patch/PatchEngine';
52
+ export { canonical, NonSerializableInputError } from './shared/canonical';
53
+ export { fnv1a64 } from './shared/hash';
54
+ export { normalizeLiveInputs, resourceIdOf } from './shared/descriptor';
55
+ export type { LiveDataOf, LiveDescriptor, LiveInputsOf } from './shared/descriptor';
56
+ export type { OptimisticEntry, OptimisticList } from './client/optimistic';
57
+
58
+ // Framework-free client adapter
59
+ export { liveStore, liveStoreOf, liveIdentity, LiveSlot } from './client/vanilla';
60
+ export type { LiveHandle } from './client/vanilla';
61
+
62
+ export { WebSocketTransport, LadderTransport, PollingTransport, SseClientTransport, routeIndex } from './client/transport';
63
+ export type { ClientTransport, TransportHandlers, EventSourceLike, RoutePath } from './client/transport';
64
+
65
+ // Transports
66
+ export { FanTransport } from './transport/FanTransport';
67
+ export type { OwnedTransport } from './transport/FanTransport';
68
+ export { SseTransport } from './transport/SseTransport';
69
+ export type { SseTransportOptions } from './transport/SseTransport';
70
+ export { createSseRoutes } from './transport/sse-routes';
71
+ export type { SseRouteOptions } from './transport/sse-routes';
72
+
73
+ // Conditional GET
74
+ export { LiveETagMiddleware, pathMatcher } from './http/etag';
75
+ export type { LivePollingGuard, LivePollingRequest, LiveRoutePath } from './http/etag';
76
+
77
+ // First paint
78
+ export { prefetchLive } from './resource/prefetch';
79
+ export type { LivePayload } from './resource/prefetch';
80
+ export { LiveRouteExecutionError } from './resource/route-executor';
81
+ export { hydrationKey, toHydrateMap, readHydrationPayload, HYDRATION_ATTRIBUTE } from './client/hydrate';
@@ -0,0 +1,21 @@
1
+ export const LIVE_META = Symbol('carno:live');
2
+
3
+ export type LiveShared = 'private' | 'tenant' | 'public';
4
+
5
+ export interface LiveOptions {
6
+ /** Field that identifies a row of a returned collection. */
7
+ key?: string;
8
+
9
+ /** Who may share one computed instance. */
10
+ shared?: LiveShared;
11
+
12
+ /** Manual dependency keys for data the ORM cannot observe. */
13
+ dependsOn?: string[];
14
+ }
15
+
16
+ export interface LiveMeta {
17
+ key?: string;
18
+ shared: LiveShared;
19
+ dependsOn: string[];
20
+ handlerName: string;
21
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The slice of `ObservabilityService` the live package needs.
3
+ *
4
+ * Declared structurally rather than imported so `@carno.js/core` stays an
5
+ * ordinary peer here and the metrics path is testable without one.
6
+ */
7
+ export interface MetricSink {
8
+ onMetric(name: string, value: number, tags?: Record<string, string | number | boolean>): void;
9
+ }
10
+
11
+ /**
12
+ * Names the engine publishes, and the guard that keeps a broken metrics
13
+ * backend from breaking the engine.
14
+ *
15
+ * The engine calls this unconditionally; `none()` is what makes that safe when
16
+ * no observability plugin is installed, which is the default.
17
+ */
18
+ export class LiveMetrics {
19
+ constructor(private readonly sink: MetricSink | null) {}
20
+
21
+ static none(): LiveMetrics {
22
+ return new LiveMetrics(null);
23
+ }
24
+
25
+ recompute(resource: string, producedPatch: boolean, ops: number, durationMs: number): void {
26
+ this.publish('live.recompute', 1, { resource, patched: producedPatch });
27
+ this.publish('live.recompute.ms', durationMs, { resource });
28
+
29
+ if (producedPatch) {
30
+ this.publish('live.patch.ops', ops, { resource });
31
+ }
32
+ }
33
+
34
+ invalidation(keys: number, fanout: number): void {
35
+ this.publish('live.invalidation.keys', keys);
36
+ this.publish('live.invalidation.fanout', fanout);
37
+ }
38
+
39
+ instances(count: number): void {
40
+ this.publish('live.instances', count);
41
+ }
42
+
43
+ private publish(name: string, value: number, tags?: Record<string, string | number | boolean>): void {
44
+ if (!this.sink) {
45
+ return;
46
+ }
47
+
48
+ try {
49
+ this.sink.onMetric(name, value, tags);
50
+ } catch {
51
+ // Losing a number is acceptable. Losing a recompute is not.
52
+ }
53
+ }
54
+ }
@@ -0,0 +1,235 @@
1
+ import type { PatchOp, PathSegment } from './types';
2
+
3
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
4
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
5
+ return false;
6
+ }
7
+
8
+ const proto = Object.getPrototypeOf(value);
9
+ return proto === Object.prototype || proto === null;
10
+ }
11
+
12
+ function deepEqual(left: unknown, right: unknown): boolean {
13
+ if (left === right) {
14
+ return true;
15
+ }
16
+
17
+ if (Array.isArray(left) && Array.isArray(right)) {
18
+ return left.length === right.length && left.every((item, i) => deepEqual(item, right[i]));
19
+ }
20
+
21
+ if (isPlainObject(left) && isPlainObject(right)) {
22
+ const leftKeys = Object.keys(left);
23
+
24
+ if (leftKeys.length !== Object.keys(right).length) {
25
+ return false;
26
+ }
27
+
28
+ return leftKeys.every(key =>
29
+ Object.prototype.hasOwnProperty.call(right, key) && deepEqual(left[key], right[key])
30
+ );
31
+ }
32
+
33
+ return false;
34
+ }
35
+
36
+ /**
37
+ * Snapshot → snapshot diffing, and patch application with structural sharing.
38
+ * Structural sharing keeps useSyncExternalStore snapshots referentially stable
39
+ * and lets framework memoization skip untouched branches.
40
+ */
41
+ export class PatchEngine {
42
+ constructor(private readonly keyField?: string) {}
43
+
44
+ diff(prev: unknown, next: unknown): PatchOp[] {
45
+ const ops: PatchOp[] = [];
46
+ this.diffValue(prev, next, [], ops);
47
+ return ops;
48
+ }
49
+
50
+ apply(prev: unknown, ops: PatchOp[]): unknown {
51
+ if (ops.length === 0) {
52
+ return prev;
53
+ }
54
+
55
+ let root = prev;
56
+
57
+ for (const op of ops) {
58
+ root = this.applyOne(root, op);
59
+ }
60
+
61
+ return root;
62
+ }
63
+
64
+ private diffValue(prev: unknown, next: unknown, path: PathSegment[], ops: PatchOp[]): void {
65
+ if (prev === next) {
66
+ return;
67
+ }
68
+
69
+ if (Array.isArray(prev) && Array.isArray(next)) {
70
+ if (this.isKeyed(prev) && this.isKeyed(next)) {
71
+ this.diffKeyedArray(prev, next, path, ops);
72
+ return;
73
+ }
74
+
75
+ if (!deepEqual(prev, next)) {
76
+ ops.push({ op: 'set', path, value: next });
77
+ }
78
+ return;
79
+ }
80
+
81
+ if (isPlainObject(prev) && isPlainObject(next)) {
82
+ for (const key of Object.keys(prev)) {
83
+ if (!Object.prototype.hasOwnProperty.call(next, key)) {
84
+ ops.push({ op: 'unset', path: [...path, key] });
85
+ }
86
+ }
87
+
88
+ for (const key of Object.keys(next)) {
89
+ if (!Object.prototype.hasOwnProperty.call(prev, key)) {
90
+ ops.push({ op: 'set', path: [...path, key], value: next[key] });
91
+ continue;
92
+ }
93
+
94
+ this.diffValue(prev[key], next[key], [...path, key], ops);
95
+ }
96
+ return;
97
+ }
98
+
99
+ if (!deepEqual(prev, next)) {
100
+ ops.push({ op: 'set', path, value: next });
101
+ }
102
+ }
103
+
104
+ private diffKeyedArray(
105
+ prev: unknown[],
106
+ next: unknown[],
107
+ path: PathSegment[],
108
+ ops: PatchOp[]
109
+ ): void {
110
+ const prevByKey = this.indexByKey(prev);
111
+ const nextByKey = this.indexByKey(next);
112
+
113
+ for (const key of prevByKey.keys()) {
114
+ if (!nextByKey.has(key)) {
115
+ ops.push({ op: 'remove', path, key });
116
+ }
117
+ }
118
+
119
+ next.forEach((row, index) => {
120
+ const key = this.keyOf(row)!;
121
+ const before = prevByKey.get(key);
122
+
123
+ if (before === undefined || !deepEqual(before, row)) {
124
+ ops.push({ op: 'upsert', path, key, index, value: row });
125
+ }
126
+ });
127
+
128
+ const survivingPrevKeys = [...prevByKey.keys()].filter(key => nextByKey.has(key));
129
+ const nextKeys = [...nextByKey.keys()];
130
+ const orderChanged =
131
+ survivingPrevKeys.length !== nextKeys.length ||
132
+ survivingPrevKeys.some((key, i) => key !== nextKeys[i]);
133
+
134
+ if (orderChanged) {
135
+ ops.push({ op: 'order', path, keys: nextKeys });
136
+ }
137
+ }
138
+
139
+ private isKeyed(value: unknown[]): boolean {
140
+ if (!this.keyField) {
141
+ return false;
142
+ }
143
+
144
+ return value.every(item => this.keyOf(item) !== undefined);
145
+ }
146
+
147
+ private keyOf(row: unknown): string | number | undefined {
148
+ if (!this.keyField || !isPlainObject(row)) {
149
+ return undefined;
150
+ }
151
+
152
+ const value = row[this.keyField];
153
+ return typeof value === 'string' || typeof value === 'number' ? value : undefined;
154
+ }
155
+
156
+ private indexByKey(rows: unknown[]): Map<string | number, unknown> {
157
+ const index = new Map<string | number, unknown>();
158
+
159
+ for (const row of rows) {
160
+ index.set(this.keyOf(row)!, row);
161
+ }
162
+
163
+ return index;
164
+ }
165
+
166
+ private applyOne(root: unknown, op: PatchOp): unknown {
167
+ if (op.op === 'set') {
168
+ return this.replaceAt(root, op.path, () => op.value);
169
+ }
170
+
171
+ if (op.op === 'unset') {
172
+ const parentPath = op.path.slice(0, -1);
173
+ const key = op.path[op.path.length - 1];
174
+
175
+ return this.replaceAt(root, parentPath, current => {
176
+ if (!isPlainObject(current)) {
177
+ return current;
178
+ }
179
+
180
+ const clone = { ...current };
181
+ delete clone[String(key)];
182
+ return clone;
183
+ });
184
+ }
185
+
186
+ return this.replaceAt(root, op.path, current => {
187
+ const rows = Array.isArray(current) ? current : [];
188
+
189
+ if (op.op === 'remove') {
190
+ return rows.filter(row => this.keyOf(row) !== op.key);
191
+ }
192
+
193
+ if (op.op === 'upsert') {
194
+ const index = rows.findIndex(row => this.keyOf(row) === op.key);
195
+
196
+ if (index === -1) {
197
+ const clone = rows.slice();
198
+ clone.splice(Math.min(op.index, clone.length), 0, op.value);
199
+ return clone;
200
+ }
201
+
202
+ const clone = rows.slice();
203
+ clone[index] = op.value;
204
+ return clone;
205
+ }
206
+
207
+ const byKey = new Map(rows.map(row => [this.keyOf(row), row] as const));
208
+ return op.keys.map(key => byKey.get(key)).filter(row => row !== undefined);
209
+ });
210
+ }
211
+
212
+ /** Rebuild only the containers along `path`, preserving all other refs. */
213
+ private replaceAt(
214
+ root: unknown,
215
+ path: PathSegment[],
216
+ update: (current: unknown) => unknown
217
+ ): unknown {
218
+ if (path.length === 0) {
219
+ return update(root);
220
+ }
221
+
222
+ const [head, ...rest] = path;
223
+
224
+ if (Array.isArray(root)) {
225
+ const index = Number(head);
226
+ const clone = root.slice();
227
+ clone[index] = this.replaceAt(root[index], rest, update);
228
+ return clone;
229
+ }
230
+
231
+ const base = isPlainObject(root) ? root : {};
232
+ const key = String(head);
233
+ return { ...base, [key]: this.replaceAt(base[key], rest, update) };
234
+ }
235
+ }
@@ -0,0 +1,39 @@
1
+ export type PathSegment = string | number;
2
+
3
+ /** Replace the value at `path`. */
4
+ export interface SetOp {
5
+ op: 'set';
6
+ path: PathSegment[];
7
+ value: unknown;
8
+ }
9
+
10
+ /** Delete the property at `path`. */
11
+ export interface UnsetOp {
12
+ op: 'unset';
13
+ path: PathSegment[];
14
+ }
15
+
16
+ /** Insert or replace one row of a keyed array at `path`. */
17
+ export interface UpsertOp {
18
+ op: 'upsert';
19
+ path: PathSegment[];
20
+ key: string | number;
21
+ index: number;
22
+ value: unknown;
23
+ }
24
+
25
+ /** Remove one row, by key, from a keyed array at `path`. */
26
+ export interface RemoveOp {
27
+ op: 'remove';
28
+ path: PathSegment[];
29
+ key: string | number;
30
+ }
31
+
32
+ /** Final key order of a keyed array at `path`. */
33
+ export interface OrderOp {
34
+ op: 'order';
35
+ path: PathSegment[];
36
+ keys: (string | number)[];
37
+ }
38
+
39
+ export type PatchOp = SetOp | UnsetOp | UpsertOp | RemoveOp | OrderOp;