Keep the key order of records spread from values JavaScript built (#2024)

This commit is contained in:
Chris Tate
2026-10-23 15:52:49 -05:00
committed by GitHub
12 changed files with 513 additions and 28 deletions
+13
View File
@@ -1563,6 +1563,19 @@ export class LlDyn {
return descriptor;
}
/** `record.sourceSpread(built, value)`: the built record keeps value's
* kept-source order over its own declared names. */
emitRecordSourceSpread(B: BlockBuilder, built: string, value: string, shapeId: string): void {
const host = this.host;
const shape = host.recordsById.get(shapeId)!;
this.keepsRecordSources = true;
const names = this.recordNameTable(shapeId, shape);
host.declare(`declare void @scr_record_source_spread(ptr, ptr, ptr, ptr, ${host.sizeType})`);
B.line(
`call void @scr_record_source_spread(ptr ${built}, ptr ${value}, ptr ${names.names}, ptr ${names.lens}, ${host.sizeType} ${shape.fields.length})`,
);
}
/** The declared-name tables scr_record_source_note scans: parallel
* constant arrays of field-name bytes and byte lengths in declaration
* order (the order the record serializes), interned per shape. Internal
@@ -239,6 +239,15 @@ export function emitGenericLibCall(host: LlvmEmitterContext, e: LibCallExpr): Ll
host.emitPendingCheck();
return output;
}
if (e.fn === "record.sourceSpread") {
const built = host.emitReadReceiver(e.args[0]!);
const value = host.emitReadReceiver(e.args[1]!);
const type = e.args[0]!.type;
if (type.kind !== "record")
throw new InternalCompilerError("llvm emitter bug: record.sourceSpread of a non-record");
host.dyn.emitRecordSourceSpread(B, built.name, value.name, type.shapeId);
return { name: "", type: e.type };
}
if (e.fn === "dyn.typedRefIs" || e.fn === "dyn.typedRefKeyIs") {
const literal = e.args[1]!.kind === "strLit" ? e.args[1]!.value : null;
if (e.fn === "dyn.typedRefKeyIs" && literal === null)
@@ -678,6 +678,7 @@ export const LIB_FN_SYMS: Record<string, string> = {
"insp.circular": "scr_insp_circular",
// Undeclared source properties kept beside a checked record (scr_json.c).
"record.sourceHas": "scr_record_source_has",
"record.sourceSpread": "scr_record_source_spread",
// WHATWG URL + URLSearchParams (scr_url.c / scr_url_params.c):
// constructions +1; url.new, the fileURLToPath pair, the win32
// pathToFileURL flavor, and sp.fromPairs throw catchably (may-throw).
@@ -42,6 +42,7 @@ function fixture(): Fixture {
unions: new Map([["u0", { id: "u0", arms: [F64, { kind: "undefinedT" }] }]]),
classes: new Map(),
methodAccessSites: new Map(),
dynConvertible: () => true,
armTag: (unionId: string, arm: IrType): number =>
unionId === "u0" && arm.kind === "undefinedT" ? 1 : -1,
recordFieldPresent: (obj: IrExpr, shapeId: string, field: string): IrExpr => ({
@@ -873,3 +874,92 @@ test("a generator resume builds only its result record, never the value it carri
// The result record itself is built in its fixed layout: today's table.
expect(f.order(result)).toEqual(["value", "done"]);
});
/** `{ a, b }` built by a checked conversion and by the literal `{ b, a }`,
* copied by `{ ...value, a }` (evaluating the value into a binding, as
* spread literals do), observed. Answers the table and the function. */
function checkedSelfCopy(): { order: string[]; fn: IrFunction; copy: IrExpr } {
const f = fixture();
const ab = f.record(
[
["a", F64],
["b", F64],
],
["a", "b"],
);
const value = ref("value", ab) as IrExpr & { kind: "varRef" };
const self: KeyOrderSegment = { kind: "spread", shapeId: ab.shapeId, maybeAbsent: false, value };
const literal = f.literal(ab, ["a", "b"], [self, { kind: "key", name: "a", definite: true }]);
const copy: IrExpr = {
kind: "seqExpr",
stmts: [decl("value", { kind: "dynCheck", value: ref("j", { kind: "dyn" }), type: ab, loc })],
result: literal,
type: ab,
loc,
};
const fn = f.run([observe(f.literal(ab, ["b", "a"])), observe(copy)]);
return { order: f.order(ab)!, fn, copy };
}
test("a spread of a value a checked conversion built carries the value's order over", () => {
const { order, fn, copy } = checkedSelfCopy();
// Values the literal builds enumerate in its order; copies of converted
// values keep the order their record source holds.
expect(order).toEqual(["b", "a"]);
expect(copy).toMatchObject({
kind: "seqExpr",
stmts: [
{ kind: "varDecl", localId: "value" },
{ kind: "varDecl", localId: "%orderedSpread.0", init: { kind: "recordLit" } },
{
kind: "exprStmt",
expr: {
kind: "libCall",
fn: "record.sourceSpread",
args: [
{ kind: "varRef", localId: "%orderedSpread.0" },
{ kind: "varRef", localId: "value" },
],
},
},
],
result: { kind: "varRef", localId: "%orderedSpread.0" },
});
expect(fn.locals.map((local) => local.id)).toEqual(["%orderedSpread.0"]);
});
test("a spread of a converted value into a wider shape orders the wider shape", () => {
const f = fixture();
const ab = f.record(
[
["a", F64],
["b", F64],
],
["a", "b"],
);
const abc = f.record(
[
["a", F64],
["b", F64],
["c", F64],
],
["c", "a", "b"],
);
const value = ref("value", ab) as IrExpr & { kind: "varRef" };
const spread: KeyOrderSegment = {
kind: "spread",
shapeId: ab.shapeId,
maybeAbsent: false,
value,
};
const wide: IrExpr = {
kind: "seqExpr",
stmts: [decl("value", { kind: "dynCheck", value: ref("j", { kind: "dyn" }), type: ab, loc })],
result: f.literal(abc, ["a", "b", "c"], [spread, { kind: "key", name: "c", definite: true }]),
type: abc,
loc,
};
f.run([observe(f.literal(ab, ["b", "a"])), observe(wide)]);
expect(f.order(ab)).toEqual(["b", "a"]);
expect(f.order(abc)).toEqual(["b", "a", "c"]);
});
@@ -41,6 +41,12 @@
* - a literal that starts by spreading a value of its own shape copies
* that value's order. While no checked conversion builds the shape,
* every value has the one chosen order, so the copy adds no witness;
* - a literal that starts by spreading a value of a shape checked
* conversions build (an ordered spread) gives the record it builds the
* value's record source, when the value has one (record.sourceSpread).
* Every value of a shape enumerates in its table or in the order its
* record source holds, so the copy has the value's order either way,
* and the literal resolves as if the value were in its table's order;
* - a write to an optional key that may be absent appends it in Node. A
* key no construction creates ("late-only") goes after every key a
* construction can create; any other moving write goes after every key.
@@ -61,6 +67,7 @@ import {
shapeHasAccessorSlots,
typeEquals,
UNDEFINED_T,
VOID,
} from "../../ir/ir.js";
import {
everyExprChild,
@@ -71,6 +78,7 @@ import {
import type { Lowerer } from "./lowerer.js";
import * as ts from "../ts7/adapter.js";
import { ALWAYS_THROW_LIB_FNS } from "../../ir/builtin-effects.js";
import { recordMayKeepSource } from "./record-sources.js";
/** The statements a key-order helper runs for record `ref`: `walks[0]`,
* its walk over the shape's table, or, for a shape with orderVariants, a
@@ -218,6 +226,14 @@ interface ShapeFacts {
type Resolved = { order: string[] } | "unknown";
/** A literal that starts with `...value`: the sequence that evaluates the
* value into its binding and then builds the literal, and its function. */
interface LeadingSpread {
wrapper: IrExpr & { kind: "seqExpr" };
literal: IrExpr & { kind: "recordLit" };
fn: IrFunction;
}
/** `structuredClone(v)` checked back into v's own type: the copy is built
* from v's dyn view, so it creates exactly v's keys in v's order. */
function orderPreservingClone(node: IrExpr & { kind: "dynCheck" }): boolean {
@@ -412,6 +428,12 @@ export function applyKeyOrderWitnesses(lowerer: Lowerer, fns: readonly IrFunctio
// ── collection ──────────────────────────────────────────────────────
let pos = 0;
let fnName = "";
let currentFn: IrFunction | null = null;
/** Literals that start by spreading a binding, by their witness: where
* each one is built. A literal's notes are shared by its rebuilt copies,
* so `literalSites` counts every literal of each witness. */
const leadingSpreads = new Map<KeyOrderSegment[], LeadingSpread[]>();
const literalSites = new Map<KeyOrderSegment[], number>();
const loops: number[] = [];
let loopIds = 0;
const loopKey = (): string => loops.join("/");
@@ -463,8 +485,10 @@ export function applyKeyOrderWitnesses(lowerer: Lowerer, fns: readonly IrFunctio
}
const segments = notes.literalOf(e);
const copy = notes.copyOf(e);
if (segments !== undefined) f.literals.push(segments);
else if (copy !== undefined) f.copies.push({ source: copy, keys: presentKeys });
if (segments !== undefined) {
f.literals.push(segments);
literalSites.set(segments, (literalSites.get(segments) ?? 0) + 1);
} else if (copy !== undefined) f.copies.push({ source: copy, keys: presentKeys });
else f.pinned = true;
return;
}
@@ -479,6 +503,18 @@ export function applyKeyOrderWitnesses(lowerer: Lowerer, fns: readonly IrFunctio
case "assignExpr":
noteInit(e.localId, e.value);
return;
case "seqExpr": {
// A spread literal evaluates its sources into bindings first.
const literal = e.result;
if (literal.kind !== "recordLit" || currentFn === null) return;
const segments = notes.literalOf(literal);
const first = segments?.[0];
if (segments === undefined || first?.kind !== "spread" || first.value === undefined) return;
const list = leadingSpreads.get(segments) ?? [];
list.push({ wrapper: e, literal, fn: currentFn });
leadingSpreads.set(segments, list);
return;
}
case "jsonStringify":
case "dynFrom":
observedRoots.push(e.value.type);
@@ -582,6 +618,7 @@ export function applyKeyOrderWitnesses(lowerer: Lowerer, fns: readonly IrFunctio
};
for (const fn of fns) {
fnName = fn.name;
currentFn = fn;
for (const st of fn.body) stmt(st);
}
for (const id of opaqueShapes) {
@@ -698,6 +735,61 @@ export function applyKeyOrderWitnesses(lowerer: Lowerer, fns: readonly IrFunctio
return edges;
};
// ── ordered spreads ─────────────────────────────────────────────────
// A value a checked conversion builds keeps its source's key order in
// its record source when that order is not the table's (record-
// sources.ts), so every value of a shape enumerates in the table's order
// or in the order its record source holds. A literal that starts with
// `...value` creates the value's keys first, in the value's order. When
// the value's shape is one checked conversions build, the literal
// carries that order over: after it builds the record it gives the
// record a source holding the value's order (record.sourceSpread), and
// the keys the value lacks follow in the table's order, which every
// literal sequence of the shape agrees with. Such a literal resolves as
// if its source were in the source's table order, and its own shape
// counts as built at run time too, since its values may now carry a
// source. It qualifies when every value of the source has all of the
// source's keys, both shapes can keep a source, and every later spread
// whose values may carry a source only overwrites the source's keys
// (its own order cannot move them).
const orderedSpreads = new Set<readonly KeyOrderSegment[]>();
const keepsSource = (shapeId: string): boolean =>
recordMayKeepSource(lowerer, { kind: "record", shapeId });
const userKeys = (shapeId: string): string[] =>
(shapes.get(shapeId)?.fields ?? []).map((x) => x.name).filter((k) => !k.startsWith("%"));
const ordersSpread = (segments: KeyOrderSegment[], sites: readonly LeadingSpread[]): boolean => {
const first = segments[0];
if (first?.kind !== "spread" || first.maybeAbsent || first.value === undefined) return false;
const sourceId = first.shapeId;
if (facts.get(sourceId)?.runtime !== true || sites.length !== literalSites.get(segments))
return false;
const target = sites[0]!.literal.type;
if (target.kind !== "record" || !keepsSource(sourceId) || !keepsSource(target.shapeId))
return false;
const localId = first.value.localId;
const bound = ({ wrapper, literal }: LeadingSpread): boolean =>
typeEquals(literal.type, target) &&
wrapper.stmts.some(
(st) =>
st.kind === "varDecl" &&
st.localId === localId &&
st.init !== null &&
st.init.type.kind === "record" &&
st.init.type.shapeId === sourceId,
);
if (!sites.every(bound)) return false;
const sourceKeys = new Set(userKeys(sourceId));
if ([...sourceKeys].some((k) => maybeAbsent(sourceId, k))) return false;
return segments
.slice(1)
.every(
(s) =>
s.kind === "key" ||
(s.kind === "spread" &&
(facts.get(s.shapeId)?.runtime !== true ||
userKeys(s.shapeId).every((k) => sourceKeys.has(k)))),
);
};
// ── witness sequences ───────────────────────────────────────────────
/** The key sequences one literal can create, or null when a spread
* source's order is unknown. A key sits at its first occurrence that
@@ -707,15 +799,19 @@ export function applyKeyOrderWitnesses(lowerer: Lowerer, fns: readonly IrFunctio
segments: readonly KeyOrderSegment[],
keySet: ReadonlySet<string>,
resolved: (id: string) => Resolved,
raw: (id: string) => Resolved,
): string[][] | null => {
const occurrences: { key: string; definite: boolean }[] = [];
const ordered = orderedSpreads.has(segments);
for (const s of segments) {
if (s.kind === "opaque") return null;
if (s.kind === "key") {
if (keySet.has(s.name)) occurrences.push({ key: s.name, definite: s.definite });
continue;
}
const source = resolved(s.shapeId);
// An ordered spread's values without a source enumerate in the
// source's table; the others carry their order over.
const source = ordered && s === segments[0] ? raw(s.shapeId) : resolved(s.shapeId);
if (source === "unknown") return null;
for (const k of source.order) {
if (keySet.has(k))
@@ -809,13 +905,17 @@ export function applyKeyOrderWitnesses(lowerer: Lowerer, fns: readonly IrFunctio
first.kind === "spread" &&
first.shapeId === id &&
!first.maybeAbsent &&
!f.runtime &&
(!f.runtime || orderedSpreads.has(segments)) &&
segments.every((s) => s.kind !== "opaque") &&
table.every((k) => !maybeAbsent(id, k))
);
};
/** One shape's order given its sources' current orders. */
const resolveOne = (id: string, current: (source: string) => Resolved): Resolved => {
const resolveOne = (
id: string,
current: (source: string) => Resolved,
raw: (source: string) => Resolved,
): Resolved => {
const shape = shapes.get(id);
if (!inScope(shape)) return "unknown";
const table = tableOf(shape);
@@ -832,7 +932,7 @@ export function applyKeyOrderWitnesses(lowerer: Lowerer, fns: readonly IrFunctio
if (f.pinned) chain(table, edges);
for (const segments of f.literals) {
if (copiesItself(id, f, table, segments)) continue;
const sequences = literalSequences(segments, keySet, current);
const sequences = literalSequences(segments, keySet, current, raw);
if (sequences === null) return "unknown";
for (const seq of sequences) chain(seq, edges);
}
@@ -891,32 +991,57 @@ export function applyKeyOrderWitnesses(lowerer: Lowerer, fns: readonly IrFunctio
const known = result.get(id);
return known === undefined ? "unknown" : asSource(id, known);
};
for (const scc of sccs) {
const members = new Set(scc);
const guess = new Map<string, Resolved>();
for (const id of scc) {
const shape = shapes.get(id);
guess.set(id, inScope(shape) ? { order: tableOf(shape) } : "unknown");
}
const lookup = (id: string): Resolved =>
members.has(id) ? asSource(id, guess.get(id)!) : external(id);
let stable = false;
for (let round = 0; round < scc.length + 3 && !stable; round++) {
stable = true;
const resolveAll = (): void => {
result.clear();
late.clear();
for (const scc of sccs) {
const members = new Set(scc);
const guess = new Map<string, Resolved>();
for (const id of scc) {
const next = resolveOne(id, lookup);
const prev = guess.get(id)!;
const same =
next === "unknown"
? prev === "unknown"
: prev !== "unknown" && prev.order.join("\0") === next.order.join("\0");
if (!same) {
stable = false;
guess.set(id, next);
const shape = shapes.get(id);
guess.set(id, inScope(shape) ? { order: tableOf(shape) } : "unknown");
}
const lookup = (id: string): Resolved =>
members.has(id) ? asSource(id, guess.get(id)!) : external(id);
const rawLookup = (id: string): Resolved =>
members.has(id) ? guess.get(id)! : (result.get(id) ?? "unknown");
let stable = false;
for (let round = 0; round < scc.length + 3 && !stable; round++) {
stable = true;
for (const id of scc) {
const next = resolveOne(id, lookup, rawLookup);
const prev = guess.get(id)!;
const same =
next === "unknown"
? prev === "unknown"
: prev !== "unknown" && prev.order.join("\0") === next.order.join("\0");
if (!same) {
stable = false;
guess.set(id, next);
}
}
}
for (const id of scc) result.set(id, stable ? guess.get(id)! : "unknown");
}
for (const id of scc) result.set(id, stable ? guess.get(id)! : "unknown");
};
// Ordered spreads join once their source resolves; each one can let its
// own shape resolve, which a later spread of it needs.
resolveAll();
for (let grew = true; grew;) {
grew = false;
for (const [segments, sites] of leadingSpreads) {
const first = segments[0];
if (first?.kind !== "spread" || orderedSpreads.has(segments)) continue;
// A self copy resolves with its own shape.
const target = sites[0]!.literal.type;
const source = result.get(first.shapeId) ?? "unknown";
const self = target.kind === "record" && target.shapeId === first.shapeId;
if ((source === "unknown" && !self) || !ordersSpread(segments, sites)) continue;
orderedSpreads.add(segments);
if (target.kind === "record") factsOf(target.shapeId).runtime = true;
grew = true;
}
if (grew) resolveAll();
}
// ── observation ─────────────────────────────────────────────────────
@@ -925,6 +1050,19 @@ export function applyKeyOrderWitnesses(lowerer: Lowerer, fns: readonly IrFunctio
for (const id of notes.observed)
shapesIn({ kind: "record", shapeId: id }, observed, observedSeen);
for (const root of observedRoots) shapesIn(root, observed, observedSeen);
// An ordered spread's copies of values without a source take the source's
// order from its table, which must then hold the resolved order.
const orderedTarget = (sites: readonly LeadingSpread[]): string | null => {
const target = sites[0]!.literal.type;
return target.kind === "record" && (result.get(target.shapeId) ?? "unknown") !== "unknown"
? target.shapeId
: null;
};
for (const [segments, sites] of leadingSpreads) {
const first = segments[0];
if (orderedSpreads.has(segments) && first?.kind === "spread" && orderedTarget(sites) !== null)
observed.add(first.shapeId);
}
// ── apply ───────────────────────────────────────────────────────────
for (const [id, resolved] of result) {
@@ -937,6 +1075,46 @@ export function applyKeyOrderWitnesses(lowerer: Lowerer, fns: readonly IrFunctio
notes.changed.add(id);
}
// Ordered spreads carry their source's order over (see above).
let orderedResults = 0;
const rewritten = new Set<IrExpr>();
for (const [segments, sites] of leadingSpreads) {
const first = segments[0];
if (!orderedSpreads.has(segments) || first?.kind !== "spread" || first.value === undefined)
continue;
// A shape that did not resolve keeps today's table for every value.
if (orderedTarget(sites) === null) continue;
for (const { wrapper, literal, fn } of sites) {
if (rewritten.has(wrapper)) continue;
rewritten.add(wrapper);
const loc = literal.loc;
const localId = `%orderedSpread.${orderedResults++}`;
fn.locals.push({ id: localId, name: "%orderedSpread", type: literal.type, mutable: false });
const built: IrExpr = { kind: "varRef", localId, type: literal.type, loc };
const source: IrExpr = {
kind: "varRef",
localId: first.value.localId,
type: { kind: "record", shapeId: first.shapeId },
loc,
};
wrapper.stmts.push(
{ kind: "varDecl", localId, init: literal, loc },
{
kind: "exprStmt",
expr: {
kind: "libCall",
fn: "record.sourceSpread",
args: [built, source],
type: VOID,
loc,
},
loc,
},
);
wrapper.result = { kind: "varRef", localId, type: literal.type, loc };
}
}
// ── per-instance orders ─────────────────────────────────────────────
// A shape whose literals write its keys in more than one order has no
// one table. Each value then records which of the orders it was written
@@ -4458,6 +4458,11 @@ export const LIB_FN_SIGS = defineLibFnSignatures({
* (inspect, Object.keys/values/entries) then answer through its live
* checked view. The receiver is any such record type. Never throws. */
"record.sourceHas": { argTypes: [null], result: BOOL },
/** `record.sourceSpread(built, value)`: the record a literal starting
* with `...value` just built keeps the order of value's kept source
* (key-order-witness.ts ordered spreads). Both are record types. Never
* throws. */
"record.sourceSpread": { argTypes: [null, null], result: VOID },
"insp.key": { argTypes: [STRING], result: STRING },
"insp.moreItems": { argTypes: [F64], result: STRING },
"insp.end": { argTypes: [STRING, STRING, STRING, F64, BOOL, BOOL], result: STRING },
@@ -0,0 +1,7 @@
{
"order": [
"<repo>/tests/corpus/7750-key-order-javascript-spread-sources/lib.js",
"<repo>/tests/corpus/7750-key-order-javascript-spread-sources/main.ts"
],
"diags": []
}
+6
View File
@@ -1055,6 +1055,12 @@ void scr_dyn_typed_ref_bind_record(ScrDyn *d, const ScrDynTypedRecordOps *ops);
void scr_record_source_note(void *rec, const ScrDyn *src, const char *const *names,
const size_t *lens, size_t count);
bool scr_record_source_has(const void *rec);
/* A literal that starts with `...src` creates src's keys first, in src's
* order: when src kept a source, the fresh record `dst` (whose shape's
* declared names are `names`) keeps one listing the keys it declares in
* that order. Undeclared keys are not copied, as the literal copies none. */
void scr_record_source_spread(void *dst, const void *src, const char *const *names,
const size_t *lens, size_t count);
/* Object.freeze of a record a program may freeze: sets SCR_RC_FROZEN in its
* rc word (immortal records are left as they are). */
void scr_record_freeze(void *record);
+39
View File
@@ -3283,6 +3283,45 @@ void scr_record_source_note(void *rec, const ScrDyn *src, const char *const *nam
scr_record_source_put(&probe);
}
void scr_record_source_spread(void *dst, const void *src, const char *const *names,
const size_t *lens, size_t count) {
if (!scr_record_source_count || !dst || !src) return;
ScrRecordSource *found = scr_record_source_find(src);
if (!found) return;
ScrRecordSource probe = { dst, NULL, (const char *)names, (const char *)lens,
sizeof *names, sizeof *lens, count };
/* Nothing to keep when the declared keys come in the table's order and
* the keys src lacks would follow them in it anyway. */
const ScrDyn *from = found->view;
bool keep = false;
size_t next = 0;
for (size_t i = 0; i < from->v.obj.len && !keep; i++) {
const ScrDynEntry *entry = &from->v.obj.entries[i];
size_t index = scr_record_source_index(&probe, entry->key, entry->key_len);
if (index == count) continue;
keep = index != next;
next++;
}
if (!keep) return;
ScrDyn *view = scr_dyn_new_obj();
for (size_t i = 0; i < from->v.obj.len; i++) {
const ScrDynEntry *entry = &from->v.obj.entries[i];
if (scr_record_source_declared(&probe, entry->key, entry->key_len))
scr_dyn_obj_set(view, entry->key, entry->key_len, scr_dyn_retain(scr_dyn_undefined()));
}
/* The keys src lacks follow in the table's order. The view lists them
* too, so an integer key among them comes first in its own-key order, as
* the literal creates it. */
for (size_t i = 0; i < count; i++) {
if (scr_dyn_obj_get(view, names[i], lens[i])) continue;
scr_dyn_obj_set(view, names[i], lens[i], scr_dyn_retain(scr_dyn_undefined()));
}
ScrRecordSource *old = scr_record_source_find(dst);
if (old) scr_record_source_drop(old);
probe.view = view;
scr_record_source_put(&probe);
}
void scr_record_source_move(void *from, void *to) {
if (!scr_record_source_count) return;
ScrRecordSource *old = scr_record_source_find(to);
@@ -0,0 +1,14 @@
// Records JavaScript builds in its own key order, handed to TypeScript
// through typed slots: a draft from a "validator", a stored row, and a
// replacement method installed through a computed key.
export function validate(input) {
return { year: input.year, title: input.title, tags: input.tags };
}
export function storeRow(rows, id) {
rows.set(id, { title: "Stored", id, tags: [], year: 1999, createdAt: "t0", version: 1 });
}
export function replace(target, name) {
target[name] = (id) => ({ version: 7, id, year: 2001, title: "Replaced", tags: ["r"], createdAt: "t1" });
}
@@ -0,0 +1,122 @@
// Literals that start by spreading a record JavaScript built keep that
// record's key order, as Node does: a draft from JavaScript spread into a
// new record, a self copy of a record JavaScript stored or returned, and
// a wider view of either. Native drafts and copies keep the literal order.
import { replace, storeRow, validate } from "./lib.js";
interface Book {
id: string;
title: string;
year: number;
tags: readonly string[];
createdAt: string;
version: number;
}
type Draft = Omit<Book, "id" | "createdAt" | "version">;
type Patch = Partial<Draft>;
interface Availability {
copies: number;
onLoan: number;
}
type BookView = Book & { availability: Availability };
class Repository {
private readonly rows = new Map<string, Book>();
private sequence = 0;
create(draft: Draft): Book {
const book: Book = {
...draft,
tags: [...draft.tags].sort(),
id: `bk_${++this.sequence}`,
createdAt: "t" + this.sequence,
version: 1,
};
this.rows.set(book.id, book);
return book;
}
require(id: string): Book {
const row = this.rows.get(id);
if (row === undefined) throw new Error("missing " + id);
return row;
}
update(id: string, patch: Patch): Book {
const current = this.require(id);
const next: Book = { ...current, ...patch, version: current.version + 1 };
this.rows.set(id, next);
return next;
}
view(book: Book): BookView {
return { ...book, availability: { copies: 2, onLoan: book.version } };
}
table(): Map<string, Book> {
return this.rows;
}
}
function show(label: string, value: Book): void {
console.log(label, JSON.stringify(value));
console.log(" keys", Object.keys(value).join());
}
function showView(label: string, value: BookView): void {
console.log(label, JSON.stringify(value));
console.log(" keys", Object.keys(value).join());
}
const repo = new Repository();
// A native draft: the literal's order.
const native = repo.create({ title: "Native", year: 1990, tags: ["b", "a"] });
show("native", native);
showView("native view", repo.view(native));
show("native update", repo.update(native.id, { year: 1991 }));
// A draft JavaScript built in another order.
const draft: Draft = validate({ title: "Checked", year: 2020, tags: ["z", "y"] });
const checked = repo.create(draft);
show("checked", checked);
showView("checked view", repo.view(checked));
show("checked update", repo.update(checked.id, { title: "Checked 2" }));
showView("checked update view", repo.view(repo.require(checked.id)));
// A row JavaScript stored into the typed map.
storeRow(repo.table(), "js_1");
show("stored", repo.require("js_1"));
show("stored update", repo.update("js_1", { tags: ["s"] }));
showView("stored view", repo.view(repo.require("js_1")));
// A replacement method returning rows JavaScript builds.
replace(repo, "require");
show("replaced", repo.require("x"));
show("replaced update", repo.update("x", { year: 2002 }));
showView("replaced view", repo.view(repo.require("y")));
// The copies are independent records.
const again = repo.view(checked);
showView("again", again);
console.log(native.version, checked.version, again.availability.onLoan);
// An integer-named key the spread source lacks comes first, as Node
// creates integer keys first whatever the literal's order.
interface Ranked {
year: number;
title: string;
tags: readonly string[];
id: string;
"1": string;
}
function rank(source: Draft, id: string): Ranked {
return { ...source, id, "1": "gold" };
}
for (const ranked of [rank(draft, "r1"), rank({ title: "Native", year: 1990, tags: [] }, "r2")]) {
console.log("ranked", JSON.stringify(ranked));
console.log(" keys", Object.keys(ranked).join());
}
@@ -0,0 +1 @@
{ "plain": 5300, "san": 9200 }