@ultimat3/core 27.3.0 → 27.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/core",
3
- "version": "27.3.0",
3
+ "version": "27.4.0",
4
4
  "description": "Ultimate's foundation: errors, context, env, config, clock, ids, logging, telemetry, lifecycle",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -37,6 +37,6 @@
37
37
  "test": "bun test"
38
38
  },
39
39
  "dependencies": {
40
- "@ultimat3/schema": "27.3.0"
40
+ "@ultimat3/schema": "27.4.0"
41
41
  }
42
42
  }
@@ -14,6 +14,7 @@ import { CLIENT_BUILD_META } from './page-meta';
14
14
  import type { RecordEnvelope } from './record-envelope';
15
15
  import { RECORDS_HEADER } from './record-envelope';
16
16
  import { outboundSlot, pageClient } from './record-sink';
17
+ import { DATES_HEADER } from './wire-dates';
17
18
 
18
19
  export type FetchLike = (input: string, init: RequestInit) => Promise<Response>;
19
20
 
@@ -79,6 +80,8 @@ export interface TransportRequest {
79
80
  export interface Answer {
80
81
  readonly text: string;
81
82
  readonly enveloped: boolean;
83
+ /** `x-ultimate-dates`: which values of the data were instants (`wire-dates.ts`). */
84
+ readonly dates: string | null;
82
85
  }
83
86
 
84
87
  /** Called, never captured: a browser's `fetch` throws `Illegal invocation` when detached. */
@@ -111,7 +114,11 @@ export async function dispatch(
111
114
  problemError(response.status, text, req.url, stated)
112
115
  );
113
116
  }
114
- return { text, enveloped: response.headers.get(RECORDS_HEADER) === '1' };
117
+ return {
118
+ text,
119
+ enveloped: response.headers.get(RECORDS_HEADER) === '1',
120
+ dates: response.headers.get(DATES_HEADER),
121
+ };
115
122
  } catch (error) {
116
123
  const current = pageClient().scope.epoch;
117
124
  if (read && current !== issued) throw scopeChanged(req.url, issued, current);
@@ -25,6 +25,7 @@ import { notifyClientWrite } from './client-writes';
25
25
  import type { RecordEnvelope } from './record-envelope';
26
26
  import { decodeRecordEnvelope } from './record-envelope';
27
27
  import { pageClient, recordSink } from './record-sink';
28
+ import { reviveWireDates } from './wire-dates';
28
29
 
29
30
  const ONCE = { attempts: 1 } as const;
30
31
 
@@ -94,7 +95,9 @@ function unwrap(answer: Answer, url: string, read: boolean): RecordEnvelope {
94
95
  error,
95
96
  );
96
97
  }
97
- return answer.enveloped ? decodeRecordEnvelope(body) : { data: body };
98
+ const envelope = answer.enveloped ? decodeRecordEnvelope(body) : { data: body };
99
+ // In place, on this caller's own parse: a deduped read's joiners each parsed their own copy.
100
+ return { ...envelope, data: reviveWireDates(envelope.data, answer.dates) };
98
101
  }
99
102
 
100
103
  /** Adopt, then remove: a key in both is gone, never resurrected by the same answer. */
package/src/context.ts CHANGED
@@ -33,6 +33,7 @@
33
33
  import { type Actor, anonymousActor } from './actor';
34
34
  import { asyncContext } from './async-context';
35
35
  import { type Clock, systemClock } from './clock';
36
+ import { DEFAULT_LOCALE, DEFAULT_TIME_ZONE } from './default-locale';
36
37
  import { UltimateError } from './errors';
37
38
  import { finiteOption } from './finite-option';
38
39
  import { traceId as newTraceId, uuidV7 } from './ids';
@@ -157,13 +158,6 @@ const requestContext = asyncContext<Ctx>('the request context');
157
158
 
158
159
  const neverAborted = new AbortController().signal;
159
160
 
160
- /**
161
- * The framework's default locale — the ONE declaration: `@ultimat3/i18n` imports it rather than
162
- * restating it (a second `'en'` there could drift from the context's own default).
163
- */
164
- export const DEFAULT_LOCALE = 'en';
165
- export const DEFAULT_TIME_ZONE = 'UTC';
166
-
167
161
  function buildId(): string {
168
162
  return process.env['BUILD_ID'] ?? 'dev';
169
163
  }
@@ -0,0 +1,8 @@
1
+ // The framework's default locale and time zone — the ONE declaration, in a leaf of its own:
2
+ // `@ultimat3/i18n`'s translator reads the locale, and an island's translator
3
+ // (`@ultimat3/i18n/subset`) must not carry the request context (`context.ts`, its async storage)
4
+ // to read a constant.
5
+
6
+ /** `@ultimat3/i18n` imports it rather than restating it: a second `'en'` there could drift. */
7
+ export const DEFAULT_LOCALE = 'en';
8
+ export const DEFAULT_TIME_ZONE = 'UTC';
package/src/index.ts CHANGED
@@ -200,8 +200,6 @@ export { resolveConflict } from './conflict-policy';
200
200
  export type { Ctx, CtxFacts, CtxInit, CtxPatch, CtxServices, ServiceBag } from './context';
201
201
  export {
202
202
  ctxOf,
203
- DEFAULT_LOCALE,
204
- DEFAULT_TIME_ZONE,
205
203
  hasContext,
206
204
  runWithContext,
207
205
  throwIfAborted,
@@ -228,6 +226,7 @@ export {
228
226
  export type { Page } from './cursor-page';
229
227
  export { pageOf } from './cursor-page';
230
228
  export { compareDecimalText } from './decimal-order';
229
+ export { DEFAULT_LOCALE, DEFAULT_TIME_ZONE } from './default-locale';
231
230
  export type { Deprecation, DeprecationField, DeprecationRender } from './deprecation';
232
231
  export { recordDeprecatedCall, renderDeprecation } from './deprecation';
233
232
  export type { DevSecretsOptions } from './dev-secrets';
@@ -801,6 +800,7 @@ export {
801
800
  webhookSignature,
802
801
  webhookSigningString,
803
802
  } from './webhook-signature';
803
+ export { DATES_HEADER, reviveWireDates, wireDatePaths } from './wire-dates';
804
804
  /**
805
805
  * A write's public name — the digest of its idempotency key — and the server scope that carries it
806
806
  * from `@ultimat3/action`'s HTTP projection to the layers that stamp it on a `records` frame.
package/src/page.ts CHANGED
@@ -56,5 +56,7 @@ export type { PageClient, RecordSink } from './record-sink';
56
56
  export { pageClient } from './record-sink';
57
57
  // The key the theme boot script reads and the toggle island writes.
58
58
  export { THEME_STORAGE_KEY } from './theme-storage';
59
+ // What `clientTransport` revives a read's instants by: the header the server names them in.
60
+ export { DATES_HEADER, reviveWireDates } from './wire-dates';
59
61
  // A write's public name, so the page's store can recognise the `records` frame its own write made.
60
62
  export { isWriteDigest, WRITE_DIGEST_LENGTH, writeDigest } from './write-digest';
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Which values of an answer were instants. `JSON.stringify` writes a `Date` as its ISO string and
3
+ * nothing on the far side can tell it from text, so a read's row typed `Date` reached its caller as
4
+ * a string — and every app converted at its own edge (`wireDate()`). The server knows: it walks the
5
+ * answer once, names each `Date` by its path in `x-ultimate-dates`, and `clientTransport` turns
6
+ * exactly those strings back into `Date`s. The body is unchanged, so a client that ignores the
7
+ * header reads what it always read.
8
+ *
9
+ * A path is a list of segments: a string is an object key, a number an array index, and `null`
10
+ * EVERY element of an array — what a list of rows folds to (`[[null, "publishedAt"]]`), so the
11
+ * header is one entry per column, not per row. A pattern that also matches a string that was not a
12
+ * `Date` is never folded: its instants are sent by exact path, so revival is exact either way.
13
+ */
14
+
15
+ /** The response header naming an answer's instants. Absent when it holds none. */
16
+ export const DATES_HEADER = 'x-ultimate-dates';
17
+
18
+ type Segment = string | number | null;
19
+
20
+ interface Pattern {
21
+ readonly exact: Segment[][];
22
+ /** A string that was not a `Date` sits at this pattern too, so folding it would revive text. */
23
+ text: boolean;
24
+ }
25
+
26
+ const isWalkable = (value: unknown): value is object =>
27
+ typeof value === 'object' &&
28
+ value !== null &&
29
+ // `JSON.stringify` writes what `toJSON` answers, not the object's keys — nothing to name inside.
30
+ typeof (value as { toJSON?: unknown }).toJSON !== 'function';
31
+
32
+ /**
33
+ * The header value for an answer, or `undefined` when it holds no `Date`. URI-encoded JSON: a
34
+ * header value is bytes, and an object key may be any string.
35
+ */
36
+ export function wireDatePaths(value: unknown): string | undefined {
37
+ const patterns = new Map<string, Pattern>();
38
+ const ancestors = new Set<object>();
39
+ const at = (pattern: Segment[]): Pattern => {
40
+ const key = JSON.stringify(pattern);
41
+ const found = patterns.get(key) ?? { exact: [], text: false };
42
+ patterns.set(key, found);
43
+ return found;
44
+ };
45
+ const walk = (node: unknown, pattern: Segment[], exact: Segment[]): void => {
46
+ if (node instanceof Date) {
47
+ if (!Number.isNaN(node.getTime())) at(pattern).exact.push(exact);
48
+ return;
49
+ }
50
+ if (typeof node === 'string') {
51
+ at(pattern).text = true;
52
+ return;
53
+ }
54
+ // A cycle is `JSON.stringify`'s refusal to make, one step later — never a stack overflow here.
55
+ if (!isWalkable(node) || ancestors.has(node)) return;
56
+ ancestors.add(node);
57
+ if (Array.isArray(node)) {
58
+ for (let index = 0; index < node.length; index += 1) {
59
+ walk(node[index], [...pattern, null], [...exact, index]);
60
+ }
61
+ } else {
62
+ for (const [key, item] of Object.entries(node))
63
+ walk(item, [...pattern, key], [...exact, key]);
64
+ }
65
+ ancestors.delete(node);
66
+ };
67
+ walk(value, [], []);
68
+ const paths: Segment[][] = [];
69
+ for (const [key, { exact, text }] of patterns) {
70
+ if (exact.length === 0) continue;
71
+ if (text) paths.push(...exact);
72
+ else paths.push(JSON.parse(key) as Segment[]);
73
+ }
74
+ return paths.length === 0 ? undefined : encodeURIComponent(JSON.stringify(paths));
75
+ }
76
+
77
+ /**
78
+ * Every string the header names, in a freshly parsed answer, turned back into a `Date` — in place.
79
+ * A header that does not parse revives nothing: it is a statement about the body, and a proxy that
80
+ * mangled it has not mangled the body.
81
+ */
82
+ export function reviveWireDates<T>(value: T, header: string | null): T {
83
+ if (header === null || header === '') return value;
84
+ let paths: unknown;
85
+ try {
86
+ paths = JSON.parse(decodeURIComponent(header));
87
+ } catch {
88
+ return value;
89
+ }
90
+ if (!Array.isArray(paths)) return value;
91
+ const holder: { root: unknown } = { root: value };
92
+ for (const path of paths) if (Array.isArray(path)) revive(holder, 'root', path, 0);
93
+ return holder.root as T;
94
+ }
95
+
96
+ function revive(parent: object, key: string | number, path: readonly unknown[], depth: number) {
97
+ const node: unknown = (parent as Record<string | number, unknown>)[key];
98
+ if (depth === path.length) {
99
+ if (typeof node !== 'string') return;
100
+ const instant = new Date(node);
101
+ if (!Number.isNaN(instant.getTime()))
102
+ (parent as Record<string | number, unknown>)[key] = instant;
103
+ return;
104
+ }
105
+ const segment = path[depth];
106
+ if (Array.isArray(node)) {
107
+ if (segment === null) for (let i = 0; i < node.length; i += 1) revive(node, i, path, depth + 1);
108
+ else if (typeof segment === 'number' && segment < node.length)
109
+ revive(node, segment, path, depth + 1);
110
+ } else if (typeof node === 'object' && node !== null && typeof segment === 'string') {
111
+ // `JSON.parse` mints `__proto__` as an own key, and assigning through it sets the prototype.
112
+ if (segment !== '__proto__' && Object.hasOwn(node, segment)) {
113
+ revive(node, segment, path, depth + 1);
114
+ }
115
+ }
116
+ }