stitchkit 0.89.0 → 0.90.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/CHANGELOG.md +56 -0
- package/dist/application/watch-hub.d.ts +24 -2
- package/dist/application/watch-hub.d.ts.map +1 -1
- package/dist/application.js +101 -11
- package/dist/index-2na0qrfn.js +214 -0
- package/dist/live/watch-client.d.ts +2 -1
- package/dist/live/watch-client.d.ts.map +1 -1
- package/dist/live/watch-contract.d.ts +70 -4
- package/dist/live/watch-contract.d.ts.map +1 -1
- package/dist/live/watch-delta.d.ts +110 -0
- package/dist/live/watch-delta.d.ts.map +1 -0
- package/dist/live.d.ts +2 -1
- package/dist/live.d.ts.map +1 -1
- package/dist/live.js +56 -4
- package/dist/observability/changes.d.ts +55 -0
- package/dist/observability/changes.d.ts.map +1 -0
- package/dist/observability/index.d.ts +2 -0
- package/dist/observability/index.d.ts.map +1 -1
- package/dist/observability/index.js +127 -0
- package/dist/observability/spool.d.ts +53 -0
- package/dist/observability/spool.d.ts.map +1 -0
- package/llms-full.txt +143 -12
- package/package.json +1 -1
- package/dist/index-dre2ywck.js +0 -48
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"watch-contract.d.ts","sourceRoot":"","sources":["../../src/live/watch-contract.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;
|
|
1
|
+
{"version":3,"file":"watch-contract.d.ts","sourceRoot":"","sources":["../../src/live/watch-contract.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAKxB,2FAA2F;AAC3F,eAAO,MAAM,cAAc;;;;mBAkBd,CAAC;AACd,MAAM,MAAM,QAAQ,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,cAAc,CAAC,CAAC;AAEtD;;;;;;;;GAQG;AACH,eAAO,MAAM,eAAe;;;mBAGf,CAAC;AACd,MAAM,MAAM,SAAS,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,eAAe,CAAC,CAAC;AAExD,eAAO,MAAM,eAAe;;;;;;;;;;;kBAEf,CAAC;AAEd,eAAO,MAAM,mBAAmB;;;mBAOnB,CAAC;AA6Bd,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;4BAmB3B,CAAC;AAEH,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;kBAShB,CAAC;AAEd,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAC/D,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAE/D,eAAO,MAAM,UAAU,yBAAyB,CAAC;AACjD,eAAO,MAAM,WAAW,0BAA0B,CAAC;AACnD,eAAO,MAAM,WAAW,0BAA0B,CAAC;AACnD,eAAO,MAAM,WAAW,0BAA0B,CAAC;AAEnD;;;;;;GAMG;AACH,eAAO,MAAM,aAAa;;;qBAEL,IAAI;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;qBACJ,IAAI;;;;;;;;;;;;;;;;;;;;;;;;;;;;;qBAGL,IAAI;;;;;;;;;;;;qBAA8B,GAAG;;;;;;qBACpC,IAAI;;;;;;;;;CAKxB,CAAC;AAEF,wEAAwE;AACxE,wBAAgB,cAAc,CAAC,GAAG,EAAE,QAAQ,GAAG,MAAM,CAEpD"}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The difference between two answers to the same watched read.
|
|
3
|
+
*
|
|
4
|
+
* A watched read publishes the whole value on every change. For a small answer
|
|
5
|
+
* that is the right thing — a difference has its own overhead, and below a few
|
|
6
|
+
* hundred bytes the difference is the larger message. For a large one it is not:
|
|
7
|
+
* the case this exists for is a ~75 KB list answer in which two timestamps move,
|
|
8
|
+
* republished every fifteen seconds, which is a megabyte of socket per
|
|
9
|
+
* subscriber per minute to carry a hundred bytes of news.
|
|
10
|
+
*
|
|
11
|
+
* ## What it is, and what it deliberately is not
|
|
12
|
+
*
|
|
13
|
+
* It is a *structural* difference over parsed JSON: objects by changed and
|
|
14
|
+
* removed key, arrays by runs copied from the previous value plus the elements
|
|
15
|
+
* that are genuinely new. It is not a text diff and not a patch format from
|
|
16
|
+
* elsewhere (JSON Patch, RFC 7386 merge patch): both of those lose the one
|
|
17
|
+
* property this has to keep — an array that shifted by one element must cost one
|
|
18
|
+
* op, not N. RFC 7386 additionally cannot express removing an array element or
|
|
19
|
+
* distinguish `null` from "delete", and a watched read's value is arbitrary
|
|
20
|
+
* application JSON where both are ordinary.
|
|
21
|
+
*
|
|
22
|
+
* ## The rule that makes it safe to use
|
|
23
|
+
*
|
|
24
|
+
* **A difference that is not smaller than the value is not sent.** Every caller
|
|
25
|
+
* measures — {@link deltaWins} — because a difference can be larger: a list that
|
|
26
|
+
* was replaced wholesale produces one `put` per element plus the framing. There
|
|
27
|
+
* is no shape of value for which sending the delta is the only option, so the
|
|
28
|
+
* failure mode of a bad diff is a wasted comparison, never a bigger frame.
|
|
29
|
+
*
|
|
30
|
+
* ## Fidelity
|
|
31
|
+
*
|
|
32
|
+
* `apply(previous, diff(previous, next))` reconstructs `next` exactly, for every
|
|
33
|
+
* value that survives `JSON.parse(JSON.stringify(x))` — which is every value a
|
|
34
|
+
* contract read can return, because it crossed a socket to get here. The
|
|
35
|
+
* property test over random pairs is the standing proof; it is a property rather
|
|
36
|
+
* than examples because the interesting inputs are the ones nobody thinks to
|
|
37
|
+
* write down.
|
|
38
|
+
*
|
|
39
|
+
* → the hub applies this per subscriber (`application/watch-hub`), the client
|
|
40
|
+
* reassembles (`live/watch-client`).
|
|
41
|
+
*/
|
|
42
|
+
import { z } from 'zod';
|
|
43
|
+
/** One step of rebuilding an array from the previous one. */
|
|
44
|
+
export type WatchArrayOp =
|
|
45
|
+
/** Take `count` elements from the previous array starting at `from`. */
|
|
46
|
+
{
|
|
47
|
+
readonly o: 'copy';
|
|
48
|
+
readonly from: number;
|
|
49
|
+
readonly count: number;
|
|
50
|
+
}
|
|
51
|
+
/** Take the element at `from` and apply a difference to it. */
|
|
52
|
+
| {
|
|
53
|
+
readonly o: 'patch';
|
|
54
|
+
readonly from: number;
|
|
55
|
+
readonly d: WatchDelta;
|
|
56
|
+
}
|
|
57
|
+
/** An element with no counterpart in the previous array. */
|
|
58
|
+
| {
|
|
59
|
+
readonly o: 'put';
|
|
60
|
+
readonly v: unknown;
|
|
61
|
+
};
|
|
62
|
+
/** The difference between two values. */
|
|
63
|
+
export type WatchDelta =
|
|
64
|
+
/** Replace wholesale — the two values have nothing to share. */
|
|
65
|
+
{
|
|
66
|
+
readonly t: 'set';
|
|
67
|
+
readonly v: unknown;
|
|
68
|
+
} | {
|
|
69
|
+
readonly t: 'obj';
|
|
70
|
+
/** Per changed or added key, the difference to apply to it. */
|
|
71
|
+
readonly set?: Readonly<Record<string, WatchDelta>>;
|
|
72
|
+
/** Keys present before and gone now. */
|
|
73
|
+
readonly drop?: readonly string[];
|
|
74
|
+
} | {
|
|
75
|
+
readonly t: 'arr';
|
|
76
|
+
readonly ops: readonly WatchArrayOp[];
|
|
77
|
+
};
|
|
78
|
+
export declare const WatchDeltaSchema: z.ZodType<WatchDelta>;
|
|
79
|
+
export declare const WatchArrayOpSchema: z.ZodType<WatchArrayOp>;
|
|
80
|
+
/**
|
|
81
|
+
* The difference from `previous` to `next`, or `undefined` when they are equal.
|
|
82
|
+
*
|
|
83
|
+
* `undefined` is a real answer and the caller must handle it: a re-read that
|
|
84
|
+
* produced the same bytes has no news in it, and the hub already declines to
|
|
85
|
+
* publish one. It is returned rather than an empty delta so that "nothing
|
|
86
|
+
* changed" cannot be confused with "everything at the top level was replaced by
|
|
87
|
+
* an empty object".
|
|
88
|
+
*/
|
|
89
|
+
export declare function diff(previous: unknown, next: unknown): WatchDelta | undefined;
|
|
90
|
+
/**
|
|
91
|
+
* Rebuild the new value from the old one and a difference.
|
|
92
|
+
*
|
|
93
|
+
* **Throws when the difference does not fit the value it was given.** That is
|
|
94
|
+
* the contract, and the reason it is not a tolerant merge: a difference applied
|
|
95
|
+
* to the wrong base produces a plausible object that is not what the server
|
|
96
|
+
* holds, and a watched read whose face is quietly wrong is the exact failure the
|
|
97
|
+
* revision and the fingerprint exist to prevent. The caller catches and
|
|
98
|
+
* resynchronises that one key.
|
|
99
|
+
*/
|
|
100
|
+
export declare function apply(previous: unknown, delta: WatchDelta): unknown;
|
|
101
|
+
/**
|
|
102
|
+
* Whether sending the difference actually saves anything.
|
|
103
|
+
*
|
|
104
|
+
* Measured on the encoded length of both, because that is what crosses the
|
|
105
|
+
* socket — a delta with fewer *nodes* can still be the longer message once its
|
|
106
|
+
* framing is written out. Ties go to the value: identical cost, one fewer thing
|
|
107
|
+
* that can go wrong at the other end.
|
|
108
|
+
*/
|
|
109
|
+
export declare function deltaWins(delta: WatchDelta, value: unknown): boolean;
|
|
110
|
+
//# sourceMappingURL=watch-delta.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"watch-delta.d.ts","sourceRoot":"","sources":["../../src/live/watch-delta.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AACH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAGxB,6DAA6D;AAC7D,MAAM,MAAM,YAAY;AACtB,wEAAwE;AACtE;IAAE,QAAQ,CAAC,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;CAAE;AACvE,+DAA+D;GAC7D;IAAE,QAAQ,CAAC,CAAC,EAAE,OAAO,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,CAAC,EAAE,UAAU,CAAA;CAAE;AACxE,4DAA4D;GAC1D;IAAE,QAAQ,CAAC,CAAC,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC;AAE/C,yCAAyC;AACzC,MAAM,MAAM,UAAU;AACpB,gEAAgE;AAC9D;IAAE,QAAQ,CAAC,CAAC,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,CAAC,EAAE,OAAO,CAAA;CAAE,GAC1C;IACE,QAAQ,CAAC,CAAC,EAAE,KAAK,CAAC;IAClB,+DAA+D;IAC/D,QAAQ,CAAC,GAAG,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC,CAAC;IACpD,wCAAwC;IACxC,QAAQ,CAAC,IAAI,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CACnC,GACD;IAAE,QAAQ,CAAC,CAAC,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,GAAG,EAAE,SAAS,YAAY,EAAE,CAAA;CAAE,CAAC;AAEjE,eAAO,MAAM,gBAAgB,EAAE,CAAC,CAAC,OAAO,CAAC,UAAU,CAUzB,CAAC;AAE3B,eAAO,MAAM,kBAAkB,EAAE,CAAC,CAAC,OAAO,CAAC,YAAY,CAc3B,CAAC;AAiB7B;;;;;;;;GAQG;AACH,wBAAgB,IAAI,CAAC,QAAQ,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,GAAG,UAAU,GAAG,SAAS,CAK7E;AAwGD;;;;;;;;;GASG;AACH,wBAAgB,KAAK,CAAC,QAAQ,EAAE,OAAO,EAAE,KAAK,EAAE,UAAU,GAAG,OAAO,CAqCnE;AAED;;;;;;;GAOG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,UAAU,EAAE,KAAK,EAAE,OAAO,GAAG,OAAO,CAEpE"}
|
package/dist/live.d.ts
CHANGED
|
@@ -14,5 +14,6 @@
|
|
|
14
14
|
*/
|
|
15
15
|
export { defineEvents, type EventDeliveryMode, type EventPayloads, type EventsConfig, type EventsDeclaration, type EventTopicDeclaration, type EventTopicRegistry, type EventTopicsOfMode, type PolicyDecision, toRealtimeContract, type UndecidedOutcome, type WireTopic, } from './live/events.js';
|
|
16
16
|
export { createWatchClient, type RealtimeClientLike, type TypedWatchClient, type WatchClientConfig, type WatchHandle, type WatchInboundEvents, type WatchListeners, type WatchTransport, watchTransport, } from './live/watch-client.js';
|
|
17
|
-
export { WATCH_CLOSE, WATCH_OPEN, WATCH_STATE, WATCH_VALUE, type WatchKey, WatchKeySchema, type WatchStateFrame, WatchStateSchema, type WatchValueFrame, WatchValueSchema, watchContract, watchKeyString, } from './live/watch-contract.js';
|
|
17
|
+
export { WATCH_CLOSE, WATCH_OPEN, WATCH_STATE, WATCH_VALUE, type WatchHave, WatchHaveSchema, type WatchKey, WatchKeySchema, type WatchStateFrame, WatchStateSchema, type WatchValueFrame, WatchValueSchema, watchContract, watchKeyString, } from './live/watch-contract.js';
|
|
18
|
+
export { apply as applyWatchDelta, deltaWins as watchDeltaWins, diff as watchDiff, type WatchArrayOp, WatchArrayOpSchema, type WatchDelta, WatchDeltaSchema, } from './live/watch-delta.js';
|
|
18
19
|
//# sourceMappingURL=live.d.ts.map
|
package/dist/live.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"live.d.ts","sourceRoot":"","sources":["../src/live.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AACH,OAAO,EACL,YAAY,EACZ,KAAK,iBAAiB,EACtB,KAAK,aAAa,EAClB,KAAK,YAAY,EACjB,KAAK,iBAAiB,EACtB,KAAK,qBAAqB,EAC1B,KAAK,kBAAkB,EACvB,KAAK,iBAAiB,EACtB,KAAK,cAAc,EACnB,kBAAkB,EAClB,KAAK,gBAAgB,EACrB,KAAK,SAAS,GACf,MAAM,eAAe,CAAC;AACvB,OAAO,EACL,iBAAiB,EACjB,KAAK,kBAAkB,EACvB,KAAK,gBAAgB,EACrB,KAAK,iBAAiB,EACtB,KAAK,WAAW,EAChB,KAAK,kBAAkB,EACvB,KAAK,cAAc,EACnB,KAAK,cAAc,EACnB,cAAc,GACf,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EACL,WAAW,EACX,UAAU,EACV,WAAW,EACX,WAAW,EACX,KAAK,QAAQ,EACb,cAAc,EACd,KAAK,eAAe,EACpB,gBAAgB,EAChB,KAAK,eAAe,EACpB,gBAAgB,EAChB,aAAa,EACb,cAAc,GACf,MAAM,uBAAuB,CAAC"}
|
|
1
|
+
{"version":3,"file":"live.d.ts","sourceRoot":"","sources":["../src/live.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AACH,OAAO,EACL,YAAY,EACZ,KAAK,iBAAiB,EACtB,KAAK,aAAa,EAClB,KAAK,YAAY,EACjB,KAAK,iBAAiB,EACtB,KAAK,qBAAqB,EAC1B,KAAK,kBAAkB,EACvB,KAAK,iBAAiB,EACtB,KAAK,cAAc,EACnB,kBAAkB,EAClB,KAAK,gBAAgB,EACrB,KAAK,SAAS,GACf,MAAM,eAAe,CAAC;AACvB,OAAO,EACL,iBAAiB,EACjB,KAAK,kBAAkB,EACvB,KAAK,gBAAgB,EACrB,KAAK,iBAAiB,EACtB,KAAK,WAAW,EAChB,KAAK,kBAAkB,EACvB,KAAK,cAAc,EACnB,KAAK,cAAc,EACnB,cAAc,GACf,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EACL,WAAW,EACX,UAAU,EACV,WAAW,EACX,WAAW,EACX,KAAK,SAAS,EACd,eAAe,EACf,KAAK,QAAQ,EACb,cAAc,EACd,KAAK,eAAe,EACpB,gBAAgB,EAChB,KAAK,eAAe,EACpB,gBAAgB,EAChB,aAAa,EACb,cAAc,GACf,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EACL,KAAK,IAAI,eAAe,EACxB,SAAS,IAAI,cAAc,EAC3B,IAAI,IAAI,SAAS,EACjB,KAAK,YAAY,EACjB,kBAAkB,EAClB,KAAK,UAAU,EACf,gBAAgB,GACjB,MAAM,oBAAoB,CAAC"}
|
package/dist/live.js
CHANGED
|
@@ -3,12 +3,18 @@ import {
|
|
|
3
3
|
WATCH_OPEN,
|
|
4
4
|
WATCH_STATE,
|
|
5
5
|
WATCH_VALUE,
|
|
6
|
+
WatchArrayOpSchema,
|
|
7
|
+
WatchDeltaSchema,
|
|
8
|
+
WatchHaveSchema,
|
|
6
9
|
WatchKeySchema,
|
|
7
10
|
WatchStateSchema,
|
|
8
11
|
WatchValueSchema,
|
|
12
|
+
apply,
|
|
13
|
+
deltaWins,
|
|
14
|
+
diff,
|
|
9
15
|
watchContract,
|
|
10
16
|
watchKeyString
|
|
11
|
-
} from "./index-
|
|
17
|
+
} from "./index-2na0qrfn.js";
|
|
12
18
|
import"./index-ywd0y6m1.js";
|
|
13
19
|
import"./index-7gnkny4z.js";
|
|
14
20
|
import {
|
|
@@ -79,17 +85,56 @@ function createWatchClient(contract, config) {
|
|
|
79
85
|
for (const listener of [...entry.listeners])
|
|
80
86
|
listener.state?.(state);
|
|
81
87
|
}
|
|
88
|
+
function resynchronise(entry, message) {
|
|
89
|
+
entry.hasValue = false;
|
|
90
|
+
entry.value = undefined;
|
|
91
|
+
entry.revision = 0;
|
|
92
|
+
entry.fingerprint = undefined;
|
|
93
|
+
entry.opened = false;
|
|
94
|
+
publishState(entry, { key: entry.key, phase: "resync-required", message });
|
|
95
|
+
config.transport.emit(WATCH_CLOSE, { key: entry.key });
|
|
96
|
+
open(entry);
|
|
97
|
+
}
|
|
82
98
|
config.transport.on(WATCH_VALUE, (frame) => {
|
|
83
99
|
const entry = entries.get(watchKeyString(frame.key));
|
|
84
100
|
if (!entry)
|
|
85
101
|
return;
|
|
102
|
+
if (frame.kind === "unchanged") {
|
|
103
|
+
if (!entry.hasValue) {
|
|
104
|
+
resynchronise(entry, "the server confirmed a value this client does not hold");
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
entry.revision = frame.revision;
|
|
108
|
+
entry.fingerprint = frame.fingerprint;
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
86
111
|
if (entry.hasValue && frame.revision <= entry.revision)
|
|
87
112
|
return;
|
|
113
|
+
let value;
|
|
114
|
+
if (frame.kind === "full") {
|
|
115
|
+
value = frame.value;
|
|
116
|
+
} else {
|
|
117
|
+
if (!entry.hasValue || entry.revision !== frame.base) {
|
|
118
|
+
resynchronise(entry, `a difference against revision ${frame.base} arrived while holding ${entry.hasValue ? String(entry.revision) : "nothing"}`);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
value = apply(entry.value, frame.delta);
|
|
123
|
+
} catch (error) {
|
|
124
|
+
resynchronise(entry, error instanceof Error ? error.message : String(error));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (argumentsDigest({ value }) !== frame.fingerprint) {
|
|
128
|
+
resynchronise(entry, "the rebuilt value did not match the fingerprint the server sent");
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
88
132
|
entry.revision = frame.revision;
|
|
89
|
-
entry.value =
|
|
133
|
+
entry.value = value;
|
|
134
|
+
entry.fingerprint = frame.fingerprint;
|
|
90
135
|
entry.hasValue = true;
|
|
91
136
|
for (const listener of [...entry.listeners])
|
|
92
|
-
listener.value(
|
|
137
|
+
listener.value(value);
|
|
93
138
|
});
|
|
94
139
|
config.transport.on(WATCH_STATE, (frame) => {
|
|
95
140
|
const entry = entries.get(watchKeyString(frame.key));
|
|
@@ -142,7 +187,8 @@ function createWatchClient(contract, config) {
|
|
|
142
187
|
return;
|
|
143
188
|
entry.opened = true;
|
|
144
189
|
try {
|
|
145
|
-
const
|
|
190
|
+
const have = entry.hasValue && entry.fingerprint !== undefined ? { revision: entry.revision, fingerprint: entry.fingerprint } : undefined;
|
|
191
|
+
const acknowledgement = await config.transport.request(WATCH_OPEN, { key: entry.key, args: entry.args, ...have !== undefined && { have } }, { timeoutMs: openTimeoutMs });
|
|
146
192
|
if (!acknowledgement.accepted) {
|
|
147
193
|
const reason = acknowledgement.reason ?? "the server refused this watch";
|
|
148
194
|
config.onRefused?.(entry.key, reason);
|
|
@@ -216,13 +262,19 @@ function createWatchClient(contract, config) {
|
|
|
216
262
|
export {
|
|
217
263
|
watchTransport,
|
|
218
264
|
watchKeyString,
|
|
265
|
+
diff as watchDiff,
|
|
266
|
+
deltaWins as watchDeltaWins,
|
|
219
267
|
watchContract,
|
|
220
268
|
toRealtimeContract,
|
|
221
269
|
defineEvents,
|
|
222
270
|
createWatchClient,
|
|
271
|
+
apply as applyWatchDelta,
|
|
223
272
|
WatchValueSchema,
|
|
224
273
|
WatchStateSchema,
|
|
225
274
|
WatchKeySchema,
|
|
275
|
+
WatchHaveSchema,
|
|
276
|
+
WatchDeltaSchema,
|
|
277
|
+
WatchArrayOpSchema,
|
|
226
278
|
WATCH_VALUE,
|
|
227
279
|
WATCH_STATE,
|
|
228
280
|
WATCH_OPEN,
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Write the calls that changed something" — one filter, not six.
|
|
3
|
+
*
|
|
4
|
+
* Every project that audits eventually writes this predicate, and measured
|
|
5
|
+
* across six sibling consumers they had written six different ones: one audited
|
|
6
|
+
* only tool calls, one excluded `/mcp`, and two disagreed with each other on
|
|
7
|
+
* `OPTIONS`. Nobody decided any of that. They each started from the one form the
|
|
8
|
+
* `RequestEvent.httpMethod` doc gives — `(event.httpMethod ?? event.method) !==
|
|
9
|
+
* 'GET'` — which is right about `GET` and silent about the other two verbs that
|
|
10
|
+
* change nothing, and filled the gap from memory.
|
|
11
|
+
*
|
|
12
|
+
* That is the duplicated observability layer ADR 0012 exists to end, one level
|
|
13
|
+
* further in: the module owns the machinery, the project owns the policy. A
|
|
14
|
+
* predicate over a normalised event is machinery.
|
|
15
|
+
*
|
|
16
|
+
* ## The two rules, and why they are in this order
|
|
17
|
+
*
|
|
18
|
+
* **A security outcome is written whatever the verb was.** A rejected read is
|
|
19
|
+
* the single most interesting row in an audit table — it is how an attempt to
|
|
20
|
+
* reach something shows up at all — and a filter that drops every `GET` drops
|
|
21
|
+
* exactly that. This rule is first because it is the one a hand-written filter
|
|
22
|
+
* loses: the author is thinking about writes, and a refused read is not a write.
|
|
23
|
+
*
|
|
24
|
+
* **Then reads are dropped and everything else is kept.** Not "writes are kept":
|
|
25
|
+
* an unknown verb is kept, because the failure modes are not symmetric. An extra
|
|
26
|
+
* row costs bytes; a missing row costs the answer to "who changed this", and it
|
|
27
|
+
* costs it silently and only later, when the question is finally asked.
|
|
28
|
+
*/
|
|
29
|
+
import type { RequestEvent } from './event.js';
|
|
30
|
+
/**
|
|
31
|
+
* Whether this call is worth an audit row: it changed something, or it was
|
|
32
|
+
* refused for a reason the audit exists to record.
|
|
33
|
+
*
|
|
34
|
+
* Hand it to a sink as its `filter`:
|
|
35
|
+
*
|
|
36
|
+
* ```ts
|
|
37
|
+
* createObservability({
|
|
38
|
+
* request: { write: (event) => db.audit.create({ data: event }), filter: auditChanges },
|
|
39
|
+
* tools: { write: (event) => db.audit.create({ data: event }), filter: auditChanges },
|
|
40
|
+
* });
|
|
41
|
+
* ```
|
|
42
|
+
*
|
|
43
|
+
* One filter for both surfaces on purpose. A tool call carries its contract verb
|
|
44
|
+
* in `httpMethod` while its `method` is the literal `TOOL` (→ ADR 0030), so the
|
|
45
|
+
* same predicate reads a write the same way whether it arrived over HTTP, MCP or
|
|
46
|
+
* an agent — which is what keeps one audit table queryable across all three.
|
|
47
|
+
*
|
|
48
|
+
* Narrower or wider policy stays the project's: compose it.
|
|
49
|
+
*
|
|
50
|
+
* ```ts
|
|
51
|
+
* filter: (event) => auditChanges(event) && event.serviceName !== 'health',
|
|
52
|
+
* ```
|
|
53
|
+
*/
|
|
54
|
+
export declare function auditChanges(event: RequestEvent): boolean;
|
|
55
|
+
//# sourceMappingURL=changes.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"changes.d.ts","sourceRoot":"","sources":["../../src/observability/changes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAuB5C;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,YAAY,GAAG,OAAO,CAIzD"}
|
|
@@ -8,9 +8,11 @@
|
|
|
8
8
|
*/
|
|
9
9
|
export { createDimensionsProjector, createObservability, type DimensionsProjector, type DimensionsProjectorConfig, type HttpRequestCompletion, type HttpRequestObserver, type Observability, type ObservabilityConfig, type ObservabilityDrainBound, type ProjectedDimensions, type RequestEventSinkConfig, type RequestObservabilityConfig, type SinkDrop, type SinkDropReason, type SinkError, } from './audit.js';
|
|
10
10
|
export { type BoundedLoggerBounds, type BoundedLoggerOptions, createBoundedLogger, DEFAULT_REDACT_PATHS, } from './bounded-logger.js';
|
|
11
|
+
export { auditChanges } from './changes.js';
|
|
11
12
|
export { type DimensionCollision, getRequestContext, getTraceId, getUserId, type RequestContext, runWithRequestContext, type SetRequestDimensionsOptions, setRequestDimensions, setRequestEndpoint, setRequestError, setRequestUser, type WrapRequestContextOptions, wrapInRequestContext, } from './context.js';
|
|
12
13
|
export type { RequestEvent } from './event.js';
|
|
13
14
|
export { type JsonValue, measureSize, redact, type SanitizeOptions, type SizeMeasure, sanitizePayload, truncatePreview, } from './sanitize.js';
|
|
15
|
+
export { createSpooledSink, type SpooledSink, type SpooledSinkConfig, type SpoolRecovery, } from './spool.js';
|
|
14
16
|
export { type ObservabilityDrainReport, ObservabilityDrainReportSchema, type ObservabilitySinkStatus, ObservabilitySinkStatusSchema, type ObservabilityStatus, ObservabilityStatusSchema, } from './status.js';
|
|
15
17
|
export { childSpan, createTraceContext, formatTraceparent, parseTraceparent, resolvePropagationContext, resolveTraceContext, type TraceContext, } from './trace.js';
|
|
16
18
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/observability/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EACL,yBAAyB,EACzB,mBAAmB,EACnB,KAAK,mBAAmB,EACxB,KAAK,yBAAyB,EAC9B,KAAK,qBAAqB,EAC1B,KAAK,mBAAmB,EACxB,KAAK,aAAa,EAClB,KAAK,mBAAmB,EACxB,KAAK,uBAAuB,EAC5B,KAAK,mBAAmB,EACxB,KAAK,sBAAsB,EAC3B,KAAK,0BAA0B,EAC/B,KAAK,QAAQ,EACb,KAAK,cAAc,EACnB,KAAK,SAAS,GACf,MAAM,SAAS,CAAC;AACjB,OAAO,EACL,KAAK,mBAAmB,EACxB,KAAK,oBAAoB,EACzB,mBAAmB,EACnB,oBAAoB,GACrB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EACL,KAAK,kBAAkB,EACvB,iBAAiB,EACjB,UAAU,EACV,SAAS,EACT,KAAK,cAAc,EACnB,qBAAqB,EACrB,KAAK,2BAA2B,EAChC,oBAAoB,EACpB,kBAAkB,EAClB,eAAe,EACf,cAAc,EACd,KAAK,yBAAyB,EAC9B,oBAAoB,GACrB,MAAM,WAAW,CAAC;AACnB,YAAY,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAC5C,OAAO,EACL,KAAK,SAAS,EACd,WAAW,EACX,MAAM,EACN,KAAK,eAAe,EACpB,KAAK,WAAW,EAChB,eAAe,EACf,eAAe,GAChB,MAAM,YAAY,CAAC;AACpB,OAAO,EACL,KAAK,wBAAwB,EAC7B,8BAA8B,EAC9B,KAAK,uBAAuB,EAC5B,6BAA6B,EAC7B,KAAK,mBAAmB,EACxB,yBAAyB,GAC1B,MAAM,UAAU,CAAC;AAClB,OAAO,EACL,SAAS,EACT,kBAAkB,EAClB,iBAAiB,EACjB,gBAAgB,EAChB,yBAAyB,EACzB,mBAAmB,EACnB,KAAK,YAAY,GAClB,MAAM,SAAS,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/observability/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EACL,yBAAyB,EACzB,mBAAmB,EACnB,KAAK,mBAAmB,EACxB,KAAK,yBAAyB,EAC9B,KAAK,qBAAqB,EAC1B,KAAK,mBAAmB,EACxB,KAAK,aAAa,EAClB,KAAK,mBAAmB,EACxB,KAAK,uBAAuB,EAC5B,KAAK,mBAAmB,EACxB,KAAK,sBAAsB,EAC3B,KAAK,0BAA0B,EAC/B,KAAK,QAAQ,EACb,KAAK,cAAc,EACnB,KAAK,SAAS,GACf,MAAM,SAAS,CAAC;AACjB,OAAO,EACL,KAAK,mBAAmB,EACxB,KAAK,oBAAoB,EACzB,mBAAmB,EACnB,oBAAoB,GACrB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAE,YAAY,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,EACL,KAAK,kBAAkB,EACvB,iBAAiB,EACjB,UAAU,EACV,SAAS,EACT,KAAK,cAAc,EACnB,qBAAqB,EACrB,KAAK,2BAA2B,EAChC,oBAAoB,EACpB,kBAAkB,EAClB,eAAe,EACf,cAAc,EACd,KAAK,yBAAyB,EAC9B,oBAAoB,GACrB,MAAM,WAAW,CAAC;AACnB,YAAY,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAC5C,OAAO,EACL,KAAK,SAAS,EACd,WAAW,EACX,MAAM,EACN,KAAK,eAAe,EACpB,KAAK,WAAW,EAChB,eAAe,EACf,eAAe,GAChB,MAAM,YAAY,CAAC;AACpB,OAAO,EACL,iBAAiB,EACjB,KAAK,WAAW,EAChB,KAAK,iBAAiB,EACtB,KAAK,aAAa,GACnB,MAAM,SAAS,CAAC;AACjB,OAAO,EACL,KAAK,wBAAwB,EAC7B,8BAA8B,EAC9B,KAAK,uBAAuB,EAC5B,6BAA6B,EAC7B,KAAK,mBAAmB,EACxB,yBAAyB,GAC1B,MAAM,UAAU,CAAC;AAClB,OAAO,EACL,SAAS,EACT,kBAAkB,EAClB,iBAAiB,EACjB,gBAAgB,EAChB,yBAAyB,EACzB,mBAAmB,EACnB,KAAK,YAAY,GAClB,MAAM,SAAS,CAAC"}
|
|
@@ -364,6 +364,131 @@ function createBoundedLogger(options) {
|
|
|
364
364
|
debug: (message, data) => write("debug", message, data)
|
|
365
365
|
};
|
|
366
366
|
}
|
|
367
|
+
// src/observability/changes.ts
|
|
368
|
+
var READ_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
|
369
|
+
var SECURITY_STATUSES = new Set([401, 403]);
|
|
370
|
+
function auditChanges(event) {
|
|
371
|
+
if (SECURITY_STATUSES.has(event.statusCode))
|
|
372
|
+
return true;
|
|
373
|
+
const verb = event.httpMethod ?? event.method;
|
|
374
|
+
return !READ_METHODS.has(verb.toUpperCase());
|
|
375
|
+
}
|
|
376
|
+
// src/observability/spool.ts
|
|
377
|
+
import { appendFile, mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
378
|
+
import { dirname } from "node:path";
|
|
379
|
+
function reviveEvent(event) {
|
|
380
|
+
return { ...event, startedAt: new Date(event.startedAt) };
|
|
381
|
+
}
|
|
382
|
+
function createSpooledSink(config) {
|
|
383
|
+
const keyOf = config.key ?? ((event) => event.spanId);
|
|
384
|
+
const compactAfter = config.compactAfter ?? 512;
|
|
385
|
+
const undelivered = new Map;
|
|
386
|
+
let delivered = 0;
|
|
387
|
+
let tail = Promise.resolve();
|
|
388
|
+
function serialise(work) {
|
|
389
|
+
const next = tail.then(work, work);
|
|
390
|
+
tail = next.catch(() => {
|
|
391
|
+
return;
|
|
392
|
+
});
|
|
393
|
+
return next;
|
|
394
|
+
}
|
|
395
|
+
async function append(line, event) {
|
|
396
|
+
try {
|
|
397
|
+
await appendFile(config.path, `${JSON.stringify(line)}
|
|
398
|
+
`, "utf8");
|
|
399
|
+
} catch (error) {
|
|
400
|
+
if (isMissing(error)) {
|
|
401
|
+
await mkdir(dirname(config.path), { recursive: true });
|
|
402
|
+
await appendFile(config.path, `${JSON.stringify(line)}
|
|
403
|
+
`, "utf8");
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
config.onSpoolError?.({ error, ...event !== undefined && { event } });
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
async function compact() {
|
|
410
|
+
const temporary = `${config.path}.compacting`;
|
|
411
|
+
const body = [...undelivered.entries()].map(([key, event]) => `${JSON.stringify({ k: key, e: event })}
|
|
412
|
+
`).join("");
|
|
413
|
+
try {
|
|
414
|
+
await writeFile(temporary, body, "utf8");
|
|
415
|
+
await rename(temporary, config.path);
|
|
416
|
+
delivered = 0;
|
|
417
|
+
} catch (error) {
|
|
418
|
+
config.onSpoolError?.({ error });
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return {
|
|
422
|
+
async write(event) {
|
|
423
|
+
const key = keyOf(event);
|
|
424
|
+
await serialise(() => append({ k: key, e: event }, event));
|
|
425
|
+
undelivered.set(key, event);
|
|
426
|
+
await config.write(event);
|
|
427
|
+
undelivered.delete(key);
|
|
428
|
+
delivered += 1;
|
|
429
|
+
await serialise(async () => {
|
|
430
|
+
await append({ a: key });
|
|
431
|
+
if (delivered >= compactAfter)
|
|
432
|
+
await compact();
|
|
433
|
+
});
|
|
434
|
+
},
|
|
435
|
+
async recover() {
|
|
436
|
+
let body;
|
|
437
|
+
try {
|
|
438
|
+
body = await readFile(config.path, "utf8");
|
|
439
|
+
} catch (error) {
|
|
440
|
+
if (!isMissing(error))
|
|
441
|
+
config.onSpoolError?.({ error });
|
|
442
|
+
return { replayed: 0, failed: 0 };
|
|
443
|
+
}
|
|
444
|
+
const pending = new Map;
|
|
445
|
+
for (const line of body.split(`
|
|
446
|
+
`)) {
|
|
447
|
+
if (line.length === 0)
|
|
448
|
+
continue;
|
|
449
|
+
let parsed;
|
|
450
|
+
try {
|
|
451
|
+
parsed = JSON.parse(line);
|
|
452
|
+
} catch (error) {
|
|
453
|
+
config.onSpoolError?.({ error });
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
if (parsed.a !== undefined) {
|
|
457
|
+
pending.delete(parsed.a);
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
if (parsed.k !== undefined && parsed.e !== undefined) {
|
|
461
|
+
pending.set(parsed.k, reviveEvent(parsed.e));
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
const replayed = pending.size;
|
|
465
|
+
let failed = 0;
|
|
466
|
+
for (const [key, event] of pending) {
|
|
467
|
+
try {
|
|
468
|
+
await config.write(event);
|
|
469
|
+
pending.delete(key);
|
|
470
|
+
} catch (error) {
|
|
471
|
+
failed += 1;
|
|
472
|
+
config.onSpoolError?.({ error, event });
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
undelivered.clear();
|
|
476
|
+
for (const [key, event] of pending)
|
|
477
|
+
undelivered.set(key, event);
|
|
478
|
+
delivered = 0;
|
|
479
|
+
await serialise(compact);
|
|
480
|
+
return { replayed, failed };
|
|
481
|
+
},
|
|
482
|
+
pending: () => undelivered.size
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
function code(error) {
|
|
486
|
+
if (typeof error !== "object" || error === null)
|
|
487
|
+
return;
|
|
488
|
+
const value = Reflect.get(error, "code");
|
|
489
|
+
return typeof value === "string" ? value : undefined;
|
|
490
|
+
}
|
|
491
|
+
var isMissing = (error) => code(error) === "ENOENT";
|
|
367
492
|
export {
|
|
368
493
|
wrapInRequestContext,
|
|
369
494
|
truncatePreview,
|
|
@@ -383,10 +508,12 @@ export {
|
|
|
383
508
|
getRequestContext,
|
|
384
509
|
formatTraceparent,
|
|
385
510
|
createTraceContext,
|
|
511
|
+
createSpooledSink,
|
|
386
512
|
createObservability,
|
|
387
513
|
createDimensionsProjector,
|
|
388
514
|
createBoundedLogger,
|
|
389
515
|
childSpan,
|
|
516
|
+
auditChanges,
|
|
390
517
|
ObservabilityStatusSchema,
|
|
391
518
|
ObservabilitySinkStatusSchema,
|
|
392
519
|
ObservabilityDrainReportSchema,
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { RequestEvent } from './event.js';
|
|
2
|
+
export interface SpooledSinkConfig {
|
|
3
|
+
/** The real sink — normally the project's audit-table write. */
|
|
4
|
+
write: (event: RequestEvent) => void | Promise<void>;
|
|
5
|
+
/**
|
|
6
|
+
* The spool file. Created with its directory if missing.
|
|
7
|
+
*
|
|
8
|
+
* One process, one path: see the module header.
|
|
9
|
+
*/
|
|
10
|
+
path: string;
|
|
11
|
+
/**
|
|
12
|
+
* The identity a replayed record is recognised by. Default: `event.spanId`,
|
|
13
|
+
* which is unique per call by construction.
|
|
14
|
+
*
|
|
15
|
+
* Whatever it returns is what the store must be idempotent on.
|
|
16
|
+
*/
|
|
17
|
+
key?: (event: RequestEvent) => string;
|
|
18
|
+
/**
|
|
19
|
+
* Delivered records tolerated in the file before it is rewritten without
|
|
20
|
+
* them. Default 512. The rewrite is atomic — a temporary file and a rename —
|
|
21
|
+
* so a crash during it leaves either the old file or the new one.
|
|
22
|
+
*/
|
|
23
|
+
compactAfter?: number;
|
|
24
|
+
/** Observe a spool-file failure. The event is still offered to the store. */
|
|
25
|
+
onSpoolError?: (failure: {
|
|
26
|
+
error: unknown;
|
|
27
|
+
event?: RequestEvent;
|
|
28
|
+
}) => void;
|
|
29
|
+
}
|
|
30
|
+
export interface SpoolRecovery {
|
|
31
|
+
/** Records found undelivered and offered to the store again. */
|
|
32
|
+
replayed: number;
|
|
33
|
+
/** Of those, the ones the store refused again. They stay in the file. */
|
|
34
|
+
failed: number;
|
|
35
|
+
}
|
|
36
|
+
export interface SpooledSink {
|
|
37
|
+
/** Spool, then deliver. Hand this to `RequestEventSinkConfig.write`. */
|
|
38
|
+
write(event: RequestEvent): Promise<void>;
|
|
39
|
+
/**
|
|
40
|
+
* Replay what a previous process left undelivered, then rewrite the file with
|
|
41
|
+
* whatever is still undelivered.
|
|
42
|
+
*
|
|
43
|
+
* Call it once at startup, **before** the sink is wired, and await it: a
|
|
44
|
+
* replay racing live writes can only make the file larger and the order
|
|
45
|
+
* stranger. Safe to call on a path that does not exist — that is the ordinary
|
|
46
|
+
* first run, and it reports zero rather than failing.
|
|
47
|
+
*/
|
|
48
|
+
recover(): Promise<SpoolRecovery>;
|
|
49
|
+
/** Records spooled and not yet marked delivered, in this process. */
|
|
50
|
+
pending(): number;
|
|
51
|
+
}
|
|
52
|
+
export declare function createSpooledSink(config: SpooledSinkConfig): SpooledSink;
|
|
53
|
+
//# sourceMappingURL=spool.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"spool.d.ts","sourceRoot":"","sources":["../../src/observability/spool.ts"],"names":[],"mappings":"AAoCA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAE5C,MAAM,WAAW,iBAAiB;IAChC,gEAAgE;IAChE,KAAK,EAAE,CAAC,KAAK,EAAE,YAAY,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACrD;;;;OAIG;IACH,IAAI,EAAE,MAAM,CAAC;IACb;;;;;OAKG;IACH,GAAG,CAAC,EAAE,CAAC,KAAK,EAAE,YAAY,KAAK,MAAM,CAAC;IACtC;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,6EAA6E;IAC7E,YAAY,CAAC,EAAE,CAAC,OAAO,EAAE;QAAE,KAAK,EAAE,OAAO,CAAC;QAAC,KAAK,CAAC,EAAE,YAAY,CAAA;KAAE,KAAK,IAAI,CAAC;CAC5E;AAED,MAAM,WAAW,aAAa;IAC5B,gEAAgE;IAChE,QAAQ,EAAE,MAAM,CAAC;IACjB,yEAAyE;IACzE,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,WAAW;IAC1B,wEAAwE;IACxE,KAAK,CAAC,KAAK,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1C;;;;;;;;OAQG;IACH,OAAO,IAAI,OAAO,CAAC,aAAa,CAAC,CAAC;IAClC,qEAAqE;IACrE,OAAO,IAAI,MAAM,CAAC;CACnB;AAqBD,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,iBAAiB,GAAG,WAAW,CAuHxE"}
|