cursedbelt-core 2.13.2 → 2.13.3
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/core/analytics/range.d.ts +2 -2
- package/dist/core/analytics/range.js +2 -2
- package/dist/core/analytics/schedule.d.ts +1 -1
- package/dist/core/analytics/schedule.js +3 -3
- package/dist/core/duplicates/index.d.ts +2 -2
- package/dist/core/duplicates/index.js +1 -1
- package/dist/core/file-tree/fileTreeModel.d.ts +3 -3
- package/dist/core/file-tree/fileTreeModel.js +2 -2
- package/dist/core/folder-tree/actions.d.ts +1 -1
- package/dist/core/folder-tree/actions.js +1 -1
- package/dist/core/master-lock/kdf.js +1 -1
- package/dist/core/master-lock/presence.d.ts +1 -1
- package/dist/core/master-lock/presence.js +1 -1
- package/dist/core/master-lock/wire.d.ts +1 -1
- package/dist/core/media/mediaItem.js +1 -1
- package/dist/core/media/types.d.ts +4 -4
- package/dist/core/media/videoCuts.d.ts +1 -1
- package/dist/core/media/videoCuts.js +2 -2
- package/dist/core/navigation/index.js +1 -1
- package/dist/core/navigation/navigationModel.d.ts +1 -1
- package/dist/core/navigation/navigationModel.js +1 -1
- package/dist/core/navigation/navigationSelectors.d.ts +2 -2
- package/dist/core/navigation/navigationSelectors.js +2 -2
- package/dist/core/navigation/searchCatalogue.d.ts +2 -2
- package/dist/core/navigation/searchCatalogue.js +4 -4
- package/dist/core/palette/fuzzyRank.d.ts +1 -1
- package/dist/core/palette/fuzzyRank.js +3 -3
- package/dist/core/tokens/registry.d.ts +1 -1
- package/dist/core/tokens/registry.js +1 -1
- package/dist/core/wire/__fixtures__/rowsMarshalWorker.js +2 -2
- package/dist/core/wire/index.d.ts +1 -1
- package/dist/core/wire/index.js +1 -1
- package/dist/core/wire/ingress.d.ts +4 -4
- package/dist/core/wire/ingress.js +12 -12
- package/dist/core/wire/marshalWorker.d.ts +1 -1
- package/dist/core/wire/marshalWorker.js +15 -15
- package/dist/core/wire/marshalling.d.ts +16 -16
- package/dist/core/wire/marshalling.js +13 -13
- package/dist/ctgrClient/convertRequest.js +2 -2
- package/dist/testing/bytes.d.ts +1 -1
- package/dist/testing/bytes.js +1 -1
- package/package.json +1 -1
- package/src/core/analytics/range.spec.ts +1 -1
- package/src/core/analytics/range.ts +2 -2
- package/src/core/analytics/schedule.spec.ts +3 -3
- package/src/core/analytics/schedule.ts +3 -3
- package/src/core/duplicates/index.ts +2 -2
- package/src/core/file-tree/fileTreeModel.ts +4 -4
- package/src/core/folder-tree/actions.ts +1 -1
- package/src/core/master-lock/kdf.ts +1 -1
- package/src/core/master-lock/presence.ts +1 -1
- package/src/core/master-lock/wire.ts +1 -1
- package/src/core/media/mediaItem.ts +1 -1
- package/src/core/media/types.ts +4 -4
- package/src/core/media/videoCuts.ts +2 -2
- package/src/core/navigation/index.ts +1 -1
- package/src/core/navigation/navigation.spec.ts +3 -3
- package/src/core/navigation/navigationModel.ts +2 -2
- package/src/core/navigation/navigationSelectors.ts +3 -3
- package/src/core/navigation/searchCatalogue.ts +4 -4
- package/src/core/palette/fuzzyRank.ts +3 -3
- package/src/core/tokens/registry.ts +2 -2
- package/src/core/wire/__fixtures__/rowsMarshalWorker.ts +2 -2
- package/src/core/wire/index.ts +1 -1
- package/src/core/wire/ingress.spec.ts +12 -12
- package/src/core/wire/ingress.ts +15 -15
- package/src/core/wire/marshalWorker.spec.ts +7 -7
- package/src/core/wire/marshalWorker.ts +15 -15
- package/src/core/wire/marshalling.spec.ts +3 -3
- package/src/core/wire/marshalling.ts +25 -25
- package/src/ctgrClient/convertRequest.ts +2 -2
- package/src/namedSubpathsResolve.spec.ts +1 -1
- package/src/publishShape.spec.ts +1 -1
- package/src/shippedFilesAreTracked.spec.ts +2 -2
- package/src/testing/bytes.ts +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Pure matcher + grouper for the instant client-side "pages" search layer. Ranks a
|
|
2
|
-
// derived
|
|
2
|
+
// derived catalog ({@link deriveSearchCatalogue}) against a query and buckets the
|
|
3
3
|
// hits by their owning domain. Kept pure + framework-free so it's unit-testable and
|
|
4
4
|
// shared by HeaderSearch and (task 14) the command palette. The palette upgrades the
|
|
5
5
|
// *ranking* to uFuzzy; this is the dependency-free baseline both can fall back to.
|
|
@@ -49,8 +49,8 @@ function scoreEntry(entry: SearchCatalogueEntry, q: string): number {
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
/**
|
|
52
|
-
* Filter + rank a
|
|
53
|
-
* shows nothing until the user types). Stable: equal scores keep
|
|
52
|
+
* Filter + rank a catalog by `query`. Empty/blank query → `[]` (the instant layer
|
|
53
|
+
* shows nothing until the user types). Stable: equal scores keep catalog order.
|
|
54
54
|
*/
|
|
55
55
|
export function rankCatalogue(catalogue: SearchCatalogueEntry[], query: string): CatalogueHit[] {
|
|
56
56
|
const q = norm(query);
|
|
@@ -61,7 +61,7 @@ export function rankCatalogue(catalogue: SearchCatalogueEntry[], query: string):
|
|
|
61
61
|
if (score > 0) scored.push({ ...entry, score });
|
|
62
62
|
}
|
|
63
63
|
// Stable sort by score desc — Array.prototype.sort is stable in modern engines, so
|
|
64
|
-
// equal scores retain
|
|
64
|
+
// equal scores retain catalog (authoring) order.
|
|
65
65
|
return scored.sort((a, b) => b.score - a.score);
|
|
66
66
|
}
|
|
67
67
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// The command-palette ranking layer — uFuzzy over the nav
|
|
1
|
+
// The command-palette ranking layer — uFuzzy over the nav catalog. This is the
|
|
2
2
|
// *upgrade* HeaderSearch's `rankCatalogue` (a substring scorer) cannot match: uFuzzy
|
|
3
3
|
// scores by contiguous-match proximity, so abbreviated/typeahead input ranks the
|
|
4
4
|
// right page first. Pure + synchronous + React-free (uFuzzy is regex-based, no DOM),
|
|
@@ -48,7 +48,7 @@ function haystackFor(item: NavItem): string {
|
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
50
|
* The SECOND tier: what a destination holds rather than what it is called — its description,
|
|
51
|
-
* and the group and place it sits under when the entry carries them (
|
|
51
|
+
* and the group and place it sits under when the entry carries them (catalog entries do).
|
|
52
52
|
* Owner, 2026-10-09: *"I may not know where to go but typing something like 'api' brings up the
|
|
53
53
|
* relevant page links quickly"* — a page found only by its exact name is not found by someone
|
|
54
54
|
* who does not know the name.
|
|
@@ -70,7 +70,7 @@ function rankedIndexes(haystack: string[], q: string): number[] {
|
|
|
70
70
|
|
|
71
71
|
/**
|
|
72
72
|
* Rank `items` against `query`, best match first; non-matches dropped. Generic over the
|
|
73
|
-
* item type (so callers can pass plain {@link NavItem}s *or* richer
|
|
73
|
+
* item type (so callers can pass plain {@link NavItem}s *or* richer catalog entries
|
|
74
74
|
* that carry domain/group tags for grouping — the extra fields ride straight through).
|
|
75
75
|
* An empty/blank query returns `[]` (the palette shows its cold state instead).
|
|
76
76
|
*
|
|
@@ -40,7 +40,7 @@ export interface TokenDef<S extends z.ZodType = z.ZodType> {
|
|
|
40
40
|
subtypeOf?: string;
|
|
41
41
|
/** Canonical payload injected by the V7.4 wire smoke probe. Must satisfy `schema`. */
|
|
42
42
|
sample: z.infer<S>;
|
|
43
|
-
/** True when this token receives third-party traffic — triggers the V3
|
|
43
|
+
/** True when this token receives third-party traffic — triggers the V3 marshaler gates. */
|
|
44
44
|
external?: boolean;
|
|
45
45
|
deprecated?: TokenDeprecation;
|
|
46
46
|
description: string;
|
|
@@ -493,7 +493,7 @@ export const BASE_TOKENS: readonly TokenDef[] = [
|
|
|
493
493
|
id: 'knowledge:records',
|
|
494
494
|
mode: 'value',
|
|
495
495
|
external: true,
|
|
496
|
-
description: 'Imported record rows (CSV/sheet/webhook) — external traffic,
|
|
496
|
+
description: 'Imported record rows (CSV/sheet/webhook) — external traffic, marshaler-gated (02 §7.1).',
|
|
497
497
|
schema: z.object({ rows: z.array(z.record(z.string(), z.unknown())) }),
|
|
498
498
|
sample: { rows: [{ id: '1' }] },
|
|
499
499
|
},
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* A REAL
|
|
2
|
+
* A REAL marshaling worker entry, used by `marshalWorker.spec.ts` to prove the
|
|
3
3
|
* round-trip over an actual OS thread rather than over an injected fake.
|
|
4
4
|
*
|
|
5
5
|
* This is also the shape every production entry has, so it doubles as the
|
|
6
|
-
* reference an app author copies: construct the
|
|
6
|
+
* reference an app author copies: construct the marshaler, hand it to
|
|
7
7
|
* `serveMarshalWorker`, done. The host never imports this file — it names it,
|
|
8
8
|
* and the worker imports it.
|
|
9
9
|
*/
|
package/src/core/wire/index.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* This entry exists because of Law #1, for the same reason `client/index.ts`
|
|
5
5
|
* gives that reason for `createWireHub`: an ingress route has to be able to
|
|
6
|
-
* IMPORT the
|
|
6
|
+
* IMPORT the marshaling seam, and a worker entry has to be able to import
|
|
7
7
|
* `serveMarshalWorker`. Until the Worker commit (05 §B1) neither symbol had any
|
|
8
8
|
* export path at all — `marshalInbound` was reachable only from inside this
|
|
9
9
|
* package, which is most of why it went three arcs with zero callers.
|
|
@@ -35,7 +35,7 @@ const registryWith = (marshaller: CCWireMarshaller) => {
|
|
|
35
35
|
};
|
|
36
36
|
|
|
37
37
|
describe('createMarshalledIngress', () => {
|
|
38
|
-
it('marshals a payload through the token’s registered
|
|
38
|
+
it('marshals a payload through the token’s registered marshaler', async () => {
|
|
39
39
|
const ingress = createMarshalledIngress({ registry: registryWith(rowsMarshaller()) });
|
|
40
40
|
await expect(ingress.ingest('knowledge:records', { rows: [{ id: 'a' }] })).resolves.toEqual({
|
|
41
41
|
rows: [{ id: 'a' }],
|
|
@@ -45,7 +45,7 @@ describe('createMarshalledIngress', () => {
|
|
|
45
45
|
|
|
46
46
|
it('is a 404 for an unregistered token, not a 400', async () => {
|
|
47
47
|
const ingress = createMarshalledIngress({ registry: createMarshallerRegistry() });
|
|
48
|
-
// From outside, "no
|
|
48
|
+
// From outside, "no marshaler" and "no such token" are the same fact.
|
|
49
49
|
// Distinguishing them would make an ingress an enumeration oracle for the
|
|
50
50
|
// app's whole wire surface.
|
|
51
51
|
await expect(ingress.ingest('nope', {})).rejects.toMatchObject({
|
|
@@ -55,20 +55,20 @@ describe('createMarshalledIngress', () => {
|
|
|
55
55
|
await ingress.dispose();
|
|
56
56
|
});
|
|
57
57
|
|
|
58
|
-
it('carries the
|
|
58
|
+
it('carries the marshaler’s own typed refusals through unchanged', async () => {
|
|
59
59
|
const ingress = createMarshalledIngress({
|
|
60
60
|
registry: registryWith(rowsMarshaller({ maxPayloadBytes: 8 })),
|
|
61
61
|
});
|
|
62
|
-
// 413, from the
|
|
63
|
-
// of its own about size, because the
|
|
62
|
+
// 413, from the marshaler's declared ceiling — the ingress adds no policy
|
|
63
|
+
// of its own about size, because the marshaler is where that is declared.
|
|
64
64
|
await expect(ingress.ingest('knowledge:records', { rows: [{ id: 'a-very-long-id' }] })).rejects.toMatchObject({
|
|
65
65
|
status: 413,
|
|
66
66
|
});
|
|
67
67
|
await ingress.dispose();
|
|
68
68
|
});
|
|
69
69
|
|
|
70
|
-
it('preserves the
|
|
71
|
-
// These codes are the
|
|
70
|
+
it('preserves the marshaling tier’s status codes rather than flattening them', async () => {
|
|
71
|
+
// These codes are the marshaling tier's, unchanged, and the distinction is
|
|
72
72
|
// load-bearing: a payload `validate` refuses is the CALLER's wrong shape
|
|
73
73
|
// (400), while a transform whose OUTPUT violates the token schema means an
|
|
74
74
|
// upstream provider changed shape under us (502 — not our error budget, and
|
|
@@ -114,10 +114,10 @@ describe('createMarshalledIngress', () => {
|
|
|
114
114
|
* The reason this module exists at all. `createMarshalWorkerClient` spawns a
|
|
115
115
|
* thread PER CALL, so a route handler that called it per request would leak one
|
|
116
116
|
* OS thread per webhook — a server that gets slower for a week and then dies,
|
|
117
|
-
* with nothing in the
|
|
117
|
+
* with nothing in the marshaling API to warn you. Caching per entry is not an
|
|
118
118
|
* optimization here; it is the whole point of having one owner.
|
|
119
119
|
*/
|
|
120
|
-
it('reuses ONE thread for a
|
|
120
|
+
it('reuses ONE thread for a marshaler that offloads, and disposes it once', async () => {
|
|
121
121
|
const disposed: string[] = [];
|
|
122
122
|
const ingress = createMarshalledIngress({
|
|
123
123
|
registry: registryWith(
|
|
@@ -156,7 +156,7 @@ describe('createMarshalledIngress', () => {
|
|
|
156
156
|
|
|
157
157
|
/* --------------------------------------------------------- request bodies */
|
|
158
158
|
|
|
159
|
-
it('parses a JSON body and hands the
|
|
159
|
+
it('parses a JSON body and hands the marshaler a VALUE', async () => {
|
|
160
160
|
const ingress = createMarshalledIngress({ registry: registryWith(rowsMarshaller()) });
|
|
161
161
|
const request = new Request('http://x/hook', {
|
|
162
162
|
method: 'POST',
|
|
@@ -235,7 +235,7 @@ describe('adding an ingress later is a ROUTE, not a refactor (E16/D8)', () => {
|
|
|
235
235
|
expect(ok.status).toBe(202);
|
|
236
236
|
expect(accepted).toEqual([{ rows: [{ id: 'from-the-wire' }] }]);
|
|
237
237
|
|
|
238
|
-
// And a CSV import is the SAME route with a different
|
|
238
|
+
// And a CSV import is the SAME route with a different marshaler and a
|
|
239
239
|
// different service call — which is the claim the owner's condition asked
|
|
240
240
|
// to be able to rely on.
|
|
241
241
|
await ingress.dispose();
|
|
@@ -253,7 +253,7 @@ describe('adding an ingress later is a ROUTE, not a refactor (E16/D8)', () => {
|
|
|
253
253
|
} catch (error) {
|
|
254
254
|
// A real mount uses the app's error middleware; the point asserted here
|
|
255
255
|
// is that the ingress hands up an error that already KNOWS its status,
|
|
256
|
-
// so a route never has to map
|
|
256
|
+
// so a route never has to map marshaling failures itself.
|
|
257
257
|
return c.json({ success: false }, (error as { status?: number }).status as 413);
|
|
258
258
|
}
|
|
259
259
|
});
|
package/src/core/wire/ingress.ts
CHANGED
|
@@ -13,25 +13,25 @@
|
|
|
13
13
|
* webhook endpoint that no product asked for would create a new orphan seam of
|
|
14
14
|
* exactly the kind Law #1 exists to prevent, and it would guess wrong about the
|
|
15
15
|
* part that actually differs between a webhook and an import (authentication,
|
|
16
|
-
* routing, what the
|
|
16
|
+
* routing, what the marshaled value is handed to). What does NOT differ — and
|
|
17
17
|
* what every ingress would otherwise re-derive, differently — is this:
|
|
18
18
|
*
|
|
19
19
|
* 1. reading the body as bytes without buffering a stream into RAM twice;
|
|
20
|
-
* 2. finding the
|
|
20
|
+
* 2. finding the marshaler for the token being fed;
|
|
21
21
|
* 3. owning a worker per `workerEntry` — a live OS thread, so exactly one
|
|
22
22
|
* thing must own disposing of it — and resolving it lazily and cached;
|
|
23
23
|
* 4. handing those failures up as errors that already KNOW their HTTP status,
|
|
24
|
-
* so no route re-derives the mapping. The
|
|
24
|
+
* so no route re-derives the mapping. The marshaling tier's codes are
|
|
25
25
|
* unchanged and deliberately distinct: 413 over the declared ceiling, 400
|
|
26
26
|
* for a payload `validate` refuses (the caller's shape is wrong), 502 when
|
|
27
27
|
* the TRANSFORM OUTPUT violates the token's canonical schema (an upstream
|
|
28
28
|
* provider changed shape under us — not our error budget), 503 for a
|
|
29
|
-
* payload that needs offload from a
|
|
29
|
+
* payload that needs offload from a marshaler with no worker.
|
|
30
30
|
*
|
|
31
31
|
* Step 3 is the one that would go wrong. `createMarshalWorkerClient` spawns a
|
|
32
32
|
* thread per call; a route handler that called it per request would leak one
|
|
33
33
|
* OS thread per webhook, and the symptom would be a server that gets slower for
|
|
34
|
-
* a week and then dies. Nothing about that is discoverable from the
|
|
34
|
+
* a week and then dies. Nothing about that is discoverable from the marshaling
|
|
35
35
|
* API, which is precisely why it belongs here once instead of in each ingress.
|
|
36
36
|
*
|
|
37
37
|
* ## Adding an ingress later
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
* });
|
|
48
48
|
* ```
|
|
49
49
|
*
|
|
50
|
-
* A CSV import is the same three lines with a different
|
|
50
|
+
* A CSV import is the same three lines with a different marshaler and a
|
|
51
51
|
* different service call. Neither needs to know that a worker exists.
|
|
52
52
|
*/
|
|
53
53
|
import { AppError } from '../../shared/errors.js';
|
|
@@ -59,14 +59,14 @@ import {
|
|
|
59
59
|
type MarshalWorker,
|
|
60
60
|
} from './marshalling.js';
|
|
61
61
|
|
|
62
|
-
/** No
|
|
62
|
+
/** No marshaler is registered for the token an ingress was asked to feed. */
|
|
63
63
|
export const INGRESS_TOKEN_UNKNOWN = 'INGRESS_TOKEN_UNKNOWN';
|
|
64
64
|
|
|
65
65
|
export interface MarshalledIngressOptions {
|
|
66
66
|
/** The registry the app's wire tokens were registered on. */
|
|
67
67
|
readonly registry: MarshallerRegistry;
|
|
68
68
|
/**
|
|
69
|
-
* Per-payload worker ceiling. A
|
|
69
|
+
* Per-payload worker ceiling. A marshaling worker that hangs would otherwise
|
|
70
70
|
* hold the request open for as long as the client is willing to wait.
|
|
71
71
|
*/
|
|
72
72
|
readonly workerTimeoutMs?: number;
|
|
@@ -83,12 +83,12 @@ export interface MarshalledIngress {
|
|
|
83
83
|
*
|
|
84
84
|
* @throws {AppError} `INGRESS_TOKEN_UNKNOWN` (404) when nothing is registered;
|
|
85
85
|
* `MARSHALLER_PAYLOAD_TOO_LARGE` (413/503), `MARSHALLER_VALIDATION_FAILED`
|
|
86
|
-
* (400) or `MARSHALLER_SCHEMA_VIOLATION` (502) from the
|
|
86
|
+
* (400) or `MARSHALLER_SCHEMA_VIOLATION` (502) from the marshaler itself.
|
|
87
87
|
*/
|
|
88
88
|
ingest<Out = unknown>(tokenId: string, payload: unknown): Promise<Out>;
|
|
89
89
|
/**
|
|
90
90
|
* Read a `Request` body and marshal it for `tokenId`. JSON bodies are parsed;
|
|
91
|
-
* anything else is handed to the
|
|
91
|
+
* anything else is handed to the marshaler as bytes, because a marshaler for
|
|
92
92
|
* a CSV or a binary feed wants the bytes and re-encoding them would be lossy.
|
|
93
93
|
*/
|
|
94
94
|
ingestRequest<Out = unknown>(tokenId: string, request: Request): Promise<Out>;
|
|
@@ -113,7 +113,7 @@ export const createMarshalledIngress = (options: MarshalledIngressOptions): Mars
|
|
|
113
113
|
const entry = marshaller.workerEntry;
|
|
114
114
|
if (entry === undefined) return undefined;
|
|
115
115
|
if (disposed)
|
|
116
|
-
throw new AppError('This ingress has been disposed and cannot spawn a
|
|
116
|
+
throw new AppError('This ingress has been disposed and cannot spawn a marshaling worker.', {
|
|
117
117
|
code: 'INGRESS_DISPOSED',
|
|
118
118
|
status: 503,
|
|
119
119
|
isOperational: false,
|
|
@@ -133,10 +133,10 @@ export const createMarshalledIngress = (options: MarshalledIngressOptions): Mars
|
|
|
133
133
|
const marshallerFor = (tokenId: string): CCWireMarshaller => {
|
|
134
134
|
const marshaller = options.registry.forToken(tokenId);
|
|
135
135
|
if (!marshaller)
|
|
136
|
-
// 404, not 400: from outside, a token with no
|
|
136
|
+
// 404, not 400: from outside, a token with no marshaler and a token that
|
|
137
137
|
// does not exist are the same fact, and distinguishing them would make
|
|
138
138
|
// this endpoint an enumeration oracle for the app's wire surface.
|
|
139
|
-
throw new AppError(`No
|
|
139
|
+
throw new AppError(`No marshaler is registered for wire token '${tokenId}'.`, {
|
|
140
140
|
code: INGRESS_TOKEN_UNKNOWN,
|
|
141
141
|
status: 404,
|
|
142
142
|
context: { tokenId },
|
|
@@ -153,8 +153,8 @@ export const createMarshalledIngress = (options: MarshalledIngressOptions): Mars
|
|
|
153
153
|
|
|
154
154
|
async ingestRequest<Out>(tokenId: string, request: Request): Promise<Out> {
|
|
155
155
|
const contentType = request.headers.get('content-type') ?? '';
|
|
156
|
-
// JSON is parsed here so the
|
|
157
|
-
// it would have to parse itself (and parse DIFFERENTLY per
|
|
156
|
+
// JSON is parsed here so the marshaler sees a value rather than a string
|
|
157
|
+
// it would have to parse itself (and parse DIFFERENTLY per marshaler).
|
|
158
158
|
// A malformed body is the CALLER's error — 400 — and must not read as a
|
|
159
159
|
// validation failure of a payload we never managed to decode.
|
|
160
160
|
const payload = contentType.includes('application/json')
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
2
|
+
* The marshaling Worker (05 §B1) — proven over a REAL thread.
|
|
3
3
|
*
|
|
4
4
|
* The seam has had an injectable `MarshalWorker` since it was written, and the
|
|
5
5
|
* old suite exercised that seam with a fake. What it could never show is the
|
|
@@ -33,7 +33,7 @@ const marshaller = (over: Partial<CCWireMarshaller<unknown, z.infer<typeof schem
|
|
|
33
33
|
...over,
|
|
34
34
|
}) as CCWireMarshaller<unknown, z.infer<typeof schema>>;
|
|
35
35
|
|
|
36
|
-
describe('a real
|
|
36
|
+
describe('a real marshaling worker', () => {
|
|
37
37
|
it('runs validate + transform off-thread and returns the result', async () => {
|
|
38
38
|
const client = createMarshalWorkerClient({ entry: WORKER_ENTRY, timeoutMs: 20_000 });
|
|
39
39
|
try {
|
|
@@ -87,7 +87,7 @@ describe('a real marshalling worker', () => {
|
|
|
87
87
|
}
|
|
88
88
|
}, 30_000);
|
|
89
89
|
|
|
90
|
-
it('is the DEFAULT path: marshalInbound resolves the
|
|
90
|
+
it('is the DEFAULT path: marshalInbound resolves the marshaler’s own workerEntry', async () => {
|
|
91
91
|
const client = createMarshalWorkerClient({ entry: WORKER_ENTRY, timeoutMs: 20_000 });
|
|
92
92
|
try {
|
|
93
93
|
let ranOnMainThread = false;
|
|
@@ -100,7 +100,7 @@ describe('a real marshalling worker', () => {
|
|
|
100
100
|
}),
|
|
101
101
|
{ rows: [{ id: 'x' }] },
|
|
102
102
|
// No `worker:` — the point of the commit is that an ingress route wires
|
|
103
|
-
// nothing per-
|
|
103
|
+
// nothing per-marshaler; it supplies one resolver and offload happens.
|
|
104
104
|
{ offloadThresholdBytes: 4, resolveWorker: () => client },
|
|
105
105
|
);
|
|
106
106
|
expect(result).toEqual({ rows: [{ id: 'worker:x' }] });
|
|
@@ -162,17 +162,17 @@ describe('marshal worker lifecycle', () => {
|
|
|
162
162
|
});
|
|
163
163
|
|
|
164
164
|
describe('requiresWorkerOffload — a derived fact, not a declared one', () => {
|
|
165
|
-
it('is false for a
|
|
165
|
+
it('is false for a marshaler that can never exceed the main-thread limit', () => {
|
|
166
166
|
// 256KB ceiling: no payload it accepts can reach the 512KB boundary, so
|
|
167
167
|
// demanding a worker of it would be noise.
|
|
168
168
|
expect(requiresWorkerOffload({ maxPayloadBytes: 256 * 1024 })).toBe(false);
|
|
169
169
|
});
|
|
170
170
|
|
|
171
|
-
it('is true for a
|
|
171
|
+
it('is true for a marshaler whose ceiling is above it', () => {
|
|
172
172
|
expect(requiresWorkerOffload({ maxPayloadBytes: 2 * 1024 * 1024 })).toBe(true);
|
|
173
173
|
});
|
|
174
174
|
|
|
175
|
-
it('names the missing artifact when an offload-needing
|
|
175
|
+
it('names the missing artifact when an offload-needing marshaler has no entry', async () => {
|
|
176
176
|
// The 503 that D1 was about. It is still reachable in code — V3 is what makes
|
|
177
177
|
// it unreachable in a verified app — and when it fires it must say what to
|
|
178
178
|
// build, not merely that something is missing.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
2
|
+
* The marshaling Worker (05 §B1, second half) — `validate` + `transform` for a
|
|
3
3
|
* large inbound payload, off the thread that has to answer requests.
|
|
4
4
|
*
|
|
5
5
|
* `marshalling.ts` has always had the SIZE BOUNDARY and the injection seam; what
|
|
@@ -8,19 +8,19 @@
|
|
|
8
8
|
* the missing half: a real, long-lived worker with a request/response protocol,
|
|
9
9
|
* on both runtimes cursedbelt ships to.
|
|
10
10
|
*
|
|
11
|
-
* ## Why the
|
|
11
|
+
* ## Why the marshaler names its own worker entry
|
|
12
12
|
*
|
|
13
|
-
* A
|
|
13
|
+
* A marshaler is mostly FUNCTIONS (`validate`, `transform`, a Zod schema), and
|
|
14
14
|
* functions do not survive structured clone — a worker cannot be handed one, it
|
|
15
15
|
* has to `import` it. The obvious design (one shared worker that resolves any
|
|
16
|
-
*
|
|
17
|
-
* and keep in sync a second module listing every
|
|
16
|
+
* marshaler by id from a registry module) needs the host to declare, register
|
|
17
|
+
* and keep in sync a second module listing every marshaler, and it gives that
|
|
18
18
|
* one worker the authority to run all of them.
|
|
19
19
|
*
|
|
20
|
-
* Instead each
|
|
21
|
-
* exactly that
|
|
22
|
-
* therefore only ever run the one
|
|
23
|
-
* nothing, and — the part that matters for the gate — "can this
|
|
20
|
+
* Instead each marshaler declares `workerEntry`: the module that constructs
|
|
21
|
+
* exactly that marshaler and calls {@link serveMarshalWorker}. The worker can
|
|
22
|
+
* therefore only ever run the one marshaler it was built for, the host wires
|
|
23
|
+
* nothing, and — the part that matters for the gate — "can this marshaler
|
|
24
24
|
* offload?" stops being a boolean somebody remembered to set and becomes a
|
|
25
25
|
* question about an artifact that either exists or does not. V3 checks it
|
|
26
26
|
* statically (`marshaller-worker-offload-missing`), so the 503 path below is
|
|
@@ -133,7 +133,7 @@ export async function serveMarshalWorker<Inbound, Out>(
|
|
|
133
133
|
id: request.id,
|
|
134
134
|
ok: false,
|
|
135
135
|
code: MARSHALLER_VALIDATION_FAILED,
|
|
136
|
-
message: `Inbound payload rejected by
|
|
136
|
+
message: `Inbound payload rejected by marshaler '${marshaller.id}'.`,
|
|
137
137
|
} satisfies MarshalWorkerResponse);
|
|
138
138
|
return;
|
|
139
139
|
}
|
|
@@ -249,7 +249,7 @@ function spawnHostWorker(entry: string | URL): HostWorker {
|
|
|
249
249
|
}
|
|
250
250
|
|
|
251
251
|
/**
|
|
252
|
-
* A long-lived worker for ONE
|
|
252
|
+
* A long-lived worker for ONE marshaler, multiplexing concurrent payloads.
|
|
253
253
|
*
|
|
254
254
|
* Long-lived rather than one-worker-per-payload deliberately: thread startup
|
|
255
255
|
* plus module load is tens of milliseconds, which on a burst of imports would
|
|
@@ -304,7 +304,7 @@ export function createMarshalWorkerClient(options: MarshalWorkerClientOptions):
|
|
|
304
304
|
for (const id of [...pending.keys()])
|
|
305
305
|
settle(id, (p) =>
|
|
306
306
|
p.reject(
|
|
307
|
-
new AppError(`
|
|
307
|
+
new AppError(`Marshaling worker failed: ${message}`, {
|
|
308
308
|
code: MARSHALLER_WORKER_FAILED,
|
|
309
309
|
status: 500,
|
|
310
310
|
context: { worker: String(options.entry) },
|
|
@@ -317,7 +317,7 @@ export function createMarshalWorkerClient(options: MarshalWorkerClientOptions):
|
|
|
317
317
|
run<Inbound, Out>(_marshallerId: string, payload: Inbound): Promise<Out> {
|
|
318
318
|
if (disposed)
|
|
319
319
|
return Promise.reject(
|
|
320
|
-
new AppError('
|
|
320
|
+
new AppError('Marshaling worker has been disposed.', {
|
|
321
321
|
code: MARSHALLER_WORKER_FAILED,
|
|
322
322
|
status: 500,
|
|
323
323
|
context: { worker: String(options.entry) },
|
|
@@ -328,7 +328,7 @@ export function createMarshalWorkerClient(options: MarshalWorkerClientOptions):
|
|
|
328
328
|
const timer = setTimeout(() => {
|
|
329
329
|
pending.delete(id);
|
|
330
330
|
reject(
|
|
331
|
-
new AppError(`
|
|
331
|
+
new AppError(`Marshaling worker did not answer within ${timeoutMs}ms.`, {
|
|
332
332
|
code: MARSHALLER_WORKER_TIMEOUT,
|
|
333
333
|
status: 504,
|
|
334
334
|
context: { worker: String(options.entry), timeoutMs },
|
|
@@ -345,7 +345,7 @@ export function createMarshalWorkerClient(options: MarshalWorkerClientOptions):
|
|
|
345
345
|
for (const id of [...pending.keys()])
|
|
346
346
|
settle(id, (p) =>
|
|
347
347
|
p.reject(
|
|
348
|
-
new AppError('
|
|
348
|
+
new AppError('Marshaling worker was disposed while a payload was in flight.', {
|
|
349
349
|
code: MARSHALLER_WORKER_FAILED,
|
|
350
350
|
status: 503,
|
|
351
351
|
context: { worker: String(options.entry) },
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Port
|
|
2
|
+
* Port marshaling + the 512KB worker gate (02 §7.1).
|
|
3
3
|
*/
|
|
4
4
|
import { describe, expect, it } from 'bun:test';
|
|
5
5
|
import { z } from 'zod';
|
|
@@ -44,7 +44,7 @@ describe('marshalInbound', () => {
|
|
|
44
44
|
expect(result).toEqual({ rows: [{ id: '1' }] });
|
|
45
45
|
});
|
|
46
46
|
|
|
47
|
-
it('rejects a payload over the
|
|
47
|
+
it('rejects a payload over the marshaler’s own limit before scheduling any work', async () => {
|
|
48
48
|
let transformed = false;
|
|
49
49
|
await expect(
|
|
50
50
|
marshalInbound(
|
|
@@ -122,7 +122,7 @@ describe('marshalInbound', () => {
|
|
|
122
122
|
});
|
|
123
123
|
|
|
124
124
|
describe('createMarshallerRegistry', () => {
|
|
125
|
-
it('refuses a
|
|
125
|
+
it('refuses a marshaler with no declared payload ceiling', () => {
|
|
126
126
|
const registry = createMarshallerRegistry();
|
|
127
127
|
expect(() => registry.register('knowledge:records', marshaller({ maxPayloadBytes: 0 }))).toThrow(
|
|
128
128
|
/maxPayloadBytes/,
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Port
|
|
2
|
+
* Port marshaling + the 512KB worker gate (02 §7.1).
|
|
3
3
|
*
|
|
4
4
|
* External payloads — webhooks, third-party feeds, spreadsheet imports — never
|
|
5
|
-
* touch component code. A
|
|
5
|
+
* touch component code. A marshaler registered on the token is the only entry,
|
|
6
6
|
* and its `transform` output is checked against the token's canonical Zod schema
|
|
7
7
|
* (V3 asserts this statically; here it is enforced at runtime too, because a
|
|
8
8
|
* provider can change its shape without anyone recompiling).
|
|
@@ -21,7 +21,7 @@ import { AppError } from '../../shared/errors.js';
|
|
|
21
21
|
export const MARSHALLER_WORKER_OFFLOAD_BYTES = 512 * 1024;
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
|
-
* The ONE
|
|
24
|
+
* The ONE marshaler contract (05 §B1, unified 2026-07-26).
|
|
25
25
|
*
|
|
26
26
|
* There were TWO interfaces of this name in this package: this one, and a
|
|
27
27
|
* divergent copy in `core/tokens/registry.ts` that additionally required
|
|
@@ -35,24 +35,24 @@ export const MARSHALLER_WORKER_OFFLOAD_BYTES = 512 * 1024;
|
|
|
35
35
|
*/
|
|
36
36
|
export interface CCWireMarshaller<Inbound = unknown, Out = unknown> {
|
|
37
37
|
id: string;
|
|
38
|
-
/** The wire token this
|
|
38
|
+
/** The wire token this marshaler feeds — V3 resolves it against the registry. */
|
|
39
39
|
tokenId: string;
|
|
40
40
|
/** REQUIRED — V3 `marshaller-payload-unbounded` when absent or non-positive. */
|
|
41
41
|
maxPayloadBytes: number;
|
|
42
42
|
/**
|
|
43
|
-
* The module that runs this
|
|
43
|
+
* The module that runs this marshaler's `validate`+`transform` off the main
|
|
44
44
|
* thread — an entry calling `serveMarshalWorker` (`core/wire/marshalWorker.ts`).
|
|
45
45
|
*
|
|
46
46
|
* **This replaced a `workerOffload: boolean` in the Worker commit (05 §B1).**
|
|
47
47
|
* That flag was authored by hand and asserted a capability the codebase did
|
|
48
|
-
* not have: every
|
|
48
|
+
* not have: every marshaler in the repo set it `true` while no worker
|
|
49
49
|
* existed anywhere, so the V3 gate it fed was checking that someone had
|
|
50
|
-
* remembered to type `true`. Whether a
|
|
50
|
+
* remembered to type `true`. Whether a marshaler can offload is now a
|
|
51
51
|
* question about an ARTIFACT — this entry either exists or it does not — and
|
|
52
52
|
* whether it NEEDS to is {@link requiresWorkerOffload}, derived from
|
|
53
53
|
* `maxPayloadBytes`. Neither is a promise anyone can make by hand.
|
|
54
54
|
*
|
|
55
|
-
* Optional because a
|
|
55
|
+
* Optional because a marshaler whose `maxPayloadBytes` sits under the
|
|
56
56
|
* main-thread limit can never reach the offload path; V3 requires it of
|
|
57
57
|
* exactly those that can.
|
|
58
58
|
*/
|
|
@@ -64,9 +64,9 @@ export interface CCWireMarshaller<Inbound = unknown, Out = unknown> {
|
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
/**
|
|
67
|
-
* Can this
|
|
67
|
+
* Can this marshaler ever reach the offload path? A DERIVED fact (05 §B1).
|
|
68
68
|
*
|
|
69
|
-
* A
|
|
69
|
+
* A marshaler that refuses anything over 256KB can never produce a payload
|
|
70
70
|
* above the 512KB main-thread limit, so it needs no worker and demanding one
|
|
71
71
|
* would be noise. One whose ceiling is 2MB certainly can, and without a
|
|
72
72
|
* `workerEntry` its large payloads are a guaranteed 503 — which is why V3
|
|
@@ -111,13 +111,13 @@ export interface MarshalWorker {
|
|
|
111
111
|
|
|
112
112
|
export interface MarshalOptions {
|
|
113
113
|
/**
|
|
114
|
-
* Use THIS worker instead of the one the
|
|
114
|
+
* Use THIS worker instead of the one the marshaler's `workerEntry` names.
|
|
115
115
|
* Left unset in production: offload is the default path, resolved from the
|
|
116
|
-
*
|
|
116
|
+
* marshaler itself, so an ingress route needs no worker wiring of its own.
|
|
117
117
|
*/
|
|
118
118
|
worker?: MarshalWorker;
|
|
119
119
|
/**
|
|
120
|
-
* Resolves a worker for a
|
|
120
|
+
* Resolves a worker for a marshaler that declares a `workerEntry`. The host
|
|
121
121
|
* supplies this once (`createMarshalWorkerClient`, cached per entry) — it is a
|
|
122
122
|
* parameter rather than a module-level singleton because a worker is a live
|
|
123
123
|
* OS thread, and something has to own disposing of it.
|
|
@@ -128,9 +128,9 @@ export interface MarshalOptions {
|
|
|
128
128
|
}
|
|
129
129
|
|
|
130
130
|
/**
|
|
131
|
-
* Run a payload through its
|
|
131
|
+
* Run a payload through its marshaler, offloading past the size gate.
|
|
132
132
|
*
|
|
133
|
-
* Order matters: the
|
|
133
|
+
* Order matters: the marshaler's OWN `maxPayloadBytes` is checked first, so an
|
|
134
134
|
* oversized payload is refused with a typed diagnostic before any work — worker
|
|
135
135
|
* or otherwise — is scheduled for it.
|
|
136
136
|
*/
|
|
@@ -143,7 +143,7 @@ export async function marshalInbound<Inbound, Out>(
|
|
|
143
143
|
|
|
144
144
|
if (bytes > marshaller.maxPayloadBytes)
|
|
145
145
|
throw new AppError(
|
|
146
|
-
`Payload of ${bytes} bytes exceeds
|
|
146
|
+
`Payload of ${bytes} bytes exceeds marshaler '${marshaller.id}' limit of ${marshaller.maxPayloadBytes}.`,
|
|
147
147
|
{
|
|
148
148
|
code: MARSHALLER_PAYLOAD_TOO_LARGE,
|
|
149
149
|
status: 413,
|
|
@@ -155,15 +155,15 @@ export async function marshalInbound<Inbound, Out>(
|
|
|
155
155
|
|
|
156
156
|
if (bytes > threshold) {
|
|
157
157
|
// Offload is the DEFAULT path (05 §B1): an explicitly injected worker wins,
|
|
158
|
-
// otherwise the
|
|
159
|
-
// resolver. Neither present means this
|
|
158
|
+
// otherwise the marshaler's own `workerEntry` is resolved through the host's
|
|
159
|
+
// resolver. Neither present means this marshaler has no offload path at all
|
|
160
160
|
// — which V3 refuses to verify, so a verified app never reaches this throw.
|
|
161
161
|
const worker =
|
|
162
162
|
options.worker ??
|
|
163
163
|
(marshaller.workerEntry ? options.resolveWorker?.(marshaller as CCWireMarshaller<never, never>) : undefined);
|
|
164
164
|
if (!worker)
|
|
165
165
|
throw new AppError(
|
|
166
|
-
`Payload of ${bytes} bytes exceeds the ${threshold}-byte main-thread limit and
|
|
166
|
+
`Payload of ${bytes} bytes exceeds the ${threshold}-byte main-thread limit and marshaler '${marshaller.id}' has no marshaling worker.`,
|
|
167
167
|
{
|
|
168
168
|
code: MARSHALLER_PAYLOAD_TOO_LARGE,
|
|
169
169
|
status: 503,
|
|
@@ -173,8 +173,8 @@ export async function marshalInbound<Inbound, Out>(
|
|
|
173
173
|
threshold,
|
|
174
174
|
workerEntry: marshaller.workerEntry ?? null,
|
|
175
175
|
hint: marshaller.workerEntry
|
|
176
|
-
? 'The
|
|
177
|
-
: 'Give the
|
|
176
|
+
? 'The marshaler declares a workerEntry but no resolveWorker was supplied — pass createMarshalWorkerClient through MarshalOptions.resolveWorker.'
|
|
177
|
+
: 'Give the marshaler a workerEntry (a module calling serveMarshalWorker), or lower maxPayloadBytes below the main-thread limit.',
|
|
178
178
|
},
|
|
179
179
|
},
|
|
180
180
|
);
|
|
@@ -185,7 +185,7 @@ export async function marshalInbound<Inbound, Out>(
|
|
|
185
185
|
}
|
|
186
186
|
|
|
187
187
|
if (!marshaller.validate(payload))
|
|
188
|
-
throw new AppError(`Inbound payload rejected by
|
|
188
|
+
throw new AppError(`Inbound payload rejected by marshaler '${marshaller.id}'.`, {
|
|
189
189
|
code: MARSHALLER_VALIDATION_FAILED,
|
|
190
190
|
status: 400,
|
|
191
191
|
context: { marshaller: marshaller.id },
|
|
@@ -202,7 +202,7 @@ export async function marshalInbound<Inbound, Out>(
|
|
|
202
202
|
function assertSchema<Inbound, Out>(marshaller: CCWireMarshaller<Inbound, Out>, value: Out): Out {
|
|
203
203
|
const parsed = marshaller.targetZodSchema.safeParse(value);
|
|
204
204
|
if (!parsed.success)
|
|
205
|
-
throw new AppError(`
|
|
205
|
+
throw new AppError(`Marshaler '${marshaller.id}' produced output that violates its token's schema.`, {
|
|
206
206
|
code: MARSHALLER_SCHEMA_VIOLATION,
|
|
207
207
|
status: 502,
|
|
208
208
|
context: {
|
|
@@ -213,7 +213,7 @@ function assertSchema<Inbound, Out>(marshaller: CCWireMarshaller<Inbound, Out>,
|
|
|
213
213
|
return parsed.data;
|
|
214
214
|
}
|
|
215
215
|
|
|
216
|
-
/** The registry of
|
|
216
|
+
/** The registry of marshalers, keyed by token id (02 §7.1: registered ON the token). */
|
|
217
217
|
export interface MarshallerRegistry {
|
|
218
218
|
register(tokenId: string, marshaller: CCWireMarshaller): void;
|
|
219
219
|
forToken(tokenId: string): CCWireMarshaller | undefined;
|
|
@@ -225,7 +225,7 @@ export function createMarshallerRegistry(): MarshallerRegistry {
|
|
|
225
225
|
return {
|
|
226
226
|
register(tokenId, marshaller) {
|
|
227
227
|
if (marshaller.maxPayloadBytes === undefined || marshaller.maxPayloadBytes <= 0)
|
|
228
|
-
throw new AppError(`
|
|
228
|
+
throw new AppError(`Marshaler '${marshaller.id}' must declare a positive maxPayloadBytes.`, {
|
|
229
229
|
code: 'marshaller-payload-unbounded',
|
|
230
230
|
status: 500,
|
|
231
231
|
context: { marshaller: marshaller.id, tokenId },
|
|
@@ -93,7 +93,7 @@ async function formDataToBody(formData: FormData): Promise<Record<string, unknow
|
|
|
93
93
|
* The request body as a JS value: a JSON string is parsed, a plain string rides as itself.
|
|
94
94
|
*
|
|
95
95
|
* 🔴 Anything else is REFUSED. The tunnel carries a JSON value or a form's files; a `File`, a
|
|
96
|
-
* `Blob`, bytes, a stream or `URLSearchParams` handed through here was JSON-
|
|
96
|
+
* `Blob`, bytes, a stream or `URLSearchParams` handed through here was JSON-serialized as `{}` —
|
|
97
97
|
* desk's import preview posted a 3.4 MB workbook and the server described "a JSON file" with `{}`
|
|
98
98
|
* as its sample (forge task 3443). A loud error at the call is the bug; a silent `{}` is data loss.
|
|
99
99
|
* Send an upload as `FormData`, which is carried whole.
|
|
@@ -101,7 +101,7 @@ async function formDataToBody(formData: FormData): Promise<Record<string, unknow
|
|
|
101
101
|
function bodyFromInit(body: BodyInit | null | undefined, method: string, url: string): unknown {
|
|
102
102
|
if (body === null || body === undefined) return undefined;
|
|
103
103
|
if (typeof body !== 'string') {
|
|
104
|
-
// A plain object or array a caller cast to `BodyInit`
|
|
104
|
+
// A plain object or array a caller cast to `BodyInit` serializes as itself — carried as before.
|
|
105
105
|
const proto = Object.getPrototypeOf(body);
|
|
106
106
|
if (Array.isArray(body) || proto === Object.prototype || proto === null) return body;
|
|
107
107
|
const shape = Object.prototype.toString.call(body).slice(8, -1);
|
|
@@ -103,7 +103,7 @@ describe('subpaths named in this repo resolve', () => {
|
|
|
103
103
|
});
|
|
104
104
|
|
|
105
105
|
it('knows what the manifest exports', () => {
|
|
106
|
-
// If `exports` is ever read wrong, every
|
|
106
|
+
// If `exports` is ever read wrong, every offense below silently becomes one.
|
|
107
107
|
expect(exported.has('')).toBe(true); // the "." root entry
|
|
108
108
|
expect(exported.has('/master-lock')).toBe(true);
|
|
109
109
|
expect(exported.has('/react')).toBe(false); // the react tier stayed in `cursedbelt` at the 2026-09-15 split
|