functionalscript 0.43.0 → 0.43.1
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/fjs/cas/evo/module.f.d.ts +2 -1
- package/fjs/cas/evo/module.f.js +8 -7
- package/fjs/cas/evo/proof.f.d.ts +4 -0
- package/fjs/cas/evo/proof.f.js +54 -0
- package/fjs/media/revision/module.f.d.ts +8 -0
- package/fjs/media/revision/module.f.js +13 -1
- package/fjs/media/revision/proof.f.d.ts +11 -0
- package/fjs/media/revision/proof.f.js +51 -1
- package/package.json +1 -1
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
import { type Effect, type Operation } from '../../effects/module.f.ts';
|
|
46
46
|
import { type Key, type MemOp } from '../../effects/memory/module.f.ts';
|
|
47
47
|
import { type Cas } from '../module.f.ts';
|
|
48
|
-
import { type Revision } from '../../media/revision/module.f.ts';
|
|
48
|
+
import { type LockMap, type Revision } from '../../media/revision/module.f.ts';
|
|
49
49
|
import { type Result } from '../../types/result/module.f.ts';
|
|
50
50
|
import { type StringMap } from '../../types/object/module.f.ts';
|
|
51
51
|
import type { Vec } from '../../types/bit_vec/module.f.ts';
|
|
@@ -98,6 +98,7 @@ export type RevisionData = {
|
|
|
98
98
|
readonly subject?: Subject | undefined;
|
|
99
99
|
readonly archived?: true | undefined;
|
|
100
100
|
readonly generation?: number | undefined;
|
|
101
|
+
readonly lock?: LockMap | undefined;
|
|
101
102
|
};
|
|
102
103
|
/**
|
|
103
104
|
* Per-subject bookkeeping: every revision hash seen for the subject, every
|
package/fjs/cas/evo/module.f.js
CHANGED
|
@@ -49,9 +49,7 @@ import { collectRead } from '../module.f.js';
|
|
|
49
49
|
import { cBase32ToVec, vecToCBase32 } from '../../basen/cbase32/module.f.js';
|
|
50
50
|
import { fromVec } from '../../text/utf8/module.f.js';
|
|
51
51
|
import { tryUtf8 } from '../../text/module.f.js';
|
|
52
|
-
import { decodeText, dialect, checkReferences, isHash } from '../../media/revision/module.f.js';
|
|
53
|
-
import { stringify } from '../../media/json/module.f.js';
|
|
54
|
-
import { identity } from '../../types/function/module.f.js';
|
|
52
|
+
import { decodeText, encodeText, dialect, checkReferences, isHash } from '../../media/revision/module.f.js';
|
|
55
53
|
import { ok, error } from '../../types/result/module.f.js';
|
|
56
54
|
import { nonEmpty, empty as elEmpty } from '../../effects/list/module.f.js';
|
|
57
55
|
import { at, definedEntries } from '../../types/object/module.f.js';
|
|
@@ -59,8 +57,6 @@ import { unwrap } from '../../types/nullable/module.f.js';
|
|
|
59
57
|
import { isNotFound } from '../../effects/node/module.f.js';
|
|
60
58
|
/** A cache with no known subjects yet — the starting point for {@link buildCache}. */
|
|
61
59
|
export const emptyCache = { bySubject: {} };
|
|
62
|
-
/** Canonical JSON encoder for a `Revision` — key order carries no meaning for detection. */
|
|
63
|
-
const toJson = stringify(identity);
|
|
64
60
|
const emptySubjectState = { hashes: [], parents: [], archived: [] };
|
|
65
61
|
/** Adds every item of `items` to `set` that isn't already there, preserving `set`'s existing order. */
|
|
66
62
|
const union = (set) => (items) => items.reduce((acc, h) => acc.includes(h) ? acc : [...acc, h], set);
|
|
@@ -77,6 +73,8 @@ const union = (set) => (items) => items.reduce((acc, h) => acc.includes(h) ? acc
|
|
|
77
73
|
* (`fjs/media/revision` `checkReferences`), so decoding here cannot fail.
|
|
78
74
|
*/
|
|
79
75
|
const canonicalHash = (h) => vecToCBase32(unwrap(cBase32ToVec(h)));
|
|
76
|
+
/** Canonicalizes every direct hash in a structurally validated flat lock map. */
|
|
77
|
+
const canonicalLock = (lock) => Object.fromEntries(definedEntries(lock).map(([subject, hash]) => [subject, canonicalHash(hash)]));
|
|
80
78
|
/** A subject's current heads: revision hashes seen that no other revision of the same subject names as a parent. */
|
|
81
79
|
const headsOf = (state) => state.hashes.filter(h => !state.parents.includes(h));
|
|
82
80
|
/**
|
|
@@ -325,6 +323,7 @@ export const addRevision = (cas) => (cacheKey) => (input) => eff(resolveParents(
|
|
|
325
323
|
snapshot: snapshotResult[1],
|
|
326
324
|
generation: computeGeneration(parentsResult[1]),
|
|
327
325
|
archived: input.archived,
|
|
326
|
+
lock: input.lock,
|
|
328
327
|
};
|
|
329
328
|
const referencesResult = checkReferences(revision);
|
|
330
329
|
if (referencesResult[0] === 'error') {
|
|
@@ -341,8 +340,9 @@ export const addRevision = (cas) => (cacheKey) => (input) => eff(resolveParents(
|
|
|
341
340
|
...revision,
|
|
342
341
|
parents: revision.parents.map(canonicalHash),
|
|
343
342
|
snapshot: canonicalHash(revision.snapshot),
|
|
343
|
+
lock: revision.lock === undefined ? undefined : canonicalLock(revision.lock),
|
|
344
344
|
};
|
|
345
|
-
const bytes = tryUtf8(
|
|
345
|
+
const bytes = tryUtf8(encodeText(canonicalRevision));
|
|
346
346
|
if (bytes === null) {
|
|
347
347
|
return pure(error('revision too large to encode'));
|
|
348
348
|
}
|
|
@@ -370,12 +370,13 @@ export const addRevision = (cas) => (cacheKey) => (input) => eff(resolveParents(
|
|
|
370
370
|
* inside `canonicalHash` is safe. Field order follows the stored blob's
|
|
371
371
|
* (minus `dialect`), which is what a JSON encoding of the result shows.
|
|
372
372
|
*/
|
|
373
|
-
const toRevisionData = ({ subject, parents, snapshot, generation, archived }) => ({
|
|
373
|
+
const toRevisionData = ({ subject, parents, snapshot, generation, archived, lock }) => ({
|
|
374
374
|
subject,
|
|
375
375
|
parents: parents.map(canonicalHash),
|
|
376
376
|
snapshot: canonicalHash(snapshot),
|
|
377
377
|
generation,
|
|
378
378
|
archived,
|
|
379
|
+
lock: lock === undefined ? undefined : canonicalLock(lock),
|
|
379
380
|
});
|
|
380
381
|
/**
|
|
381
382
|
* Second stage of {@link readRevision}: interprets an already-performed read
|
package/fjs/cas/evo/proof.f.d.ts
CHANGED
|
@@ -35,6 +35,10 @@ export declare const proof: {
|
|
|
35
35
|
revisionNonRevisionBlobIsError: () => void;
|
|
36
36
|
revisionCanonicalizesReferenceSpellings: () => void;
|
|
37
37
|
revisionRoundTripsThroughAdd: () => void;
|
|
38
|
+
revisionLockRoundTripsAndCanonicalizes: () => void;
|
|
39
|
+
revisionAbsentAndEmptyLocksRemainDistinct: () => void;
|
|
40
|
+
revisionInvalidLockValueIsRejected: () => void;
|
|
41
|
+
equivalentLockOrdersReuseOneCasAddress: () => void;
|
|
38
42
|
syncRevisionFoldsValidRevisionIntoCache: () => void;
|
|
39
43
|
syncRevisionIgnoresNonRevisionContent: () => void;
|
|
40
44
|
evoHeadUnknownSubjectIsEmpty: () => void;
|
package/fjs/cas/evo/proof.f.js
CHANGED
|
@@ -596,6 +596,60 @@ export const proof = {
|
|
|
596
596
|
assert(readded[0] === 'ok', ['expected re-add ok', readded]);
|
|
597
597
|
assertEq(readded[1], child[1]);
|
|
598
598
|
},
|
|
599
|
+
revisionLockRoundTripsAndCanonicalizes: () => {
|
|
600
|
+
const c = fileCas(sha256)(home);
|
|
601
|
+
const [state0, cacheKey] = virtual(emptyState)(initEvo(c));
|
|
602
|
+
const e = evo(c)(cacheKey);
|
|
603
|
+
const canonical = vecToCBase32(vec8(0xffn));
|
|
604
|
+
const alias = canonical.toUpperCase();
|
|
605
|
+
const [state1, added] = virtual(state0)(e.add({
|
|
606
|
+
parents: [], subject: 'doc', snapshot: canonical,
|
|
607
|
+
lock: { dependency: alias },
|
|
608
|
+
}));
|
|
609
|
+
assert(added[0] === 'ok', ['expected add ok', added]);
|
|
610
|
+
const [, result] = virtual(state1)(e.revision(added[1]));
|
|
611
|
+
assert(result[0] === 'ok', ['expected revision ok', result]);
|
|
612
|
+
assertEq(result[1].lock?.dependency, canonical);
|
|
613
|
+
},
|
|
614
|
+
revisionAbsentAndEmptyLocksRemainDistinct: () => {
|
|
615
|
+
const c = fileCas(sha256)(home);
|
|
616
|
+
const [state0, cacheKey] = virtual(emptyState)(initEvo(c));
|
|
617
|
+
const e = evo(c)(cacheKey);
|
|
618
|
+
const snapshot = vecToCBase32(vec8(0x42n));
|
|
619
|
+
const [state1, absent] = virtual(state0)(e.add({ parents: [], subject: 'absent', snapshot }));
|
|
620
|
+
assert(absent[0] === 'ok', ['expected add ok', absent]);
|
|
621
|
+
const [state2, empty] = virtual(state1)(e.add({ parents: [], subject: 'empty', snapshot, lock: {} }));
|
|
622
|
+
assert(empty[0] === 'ok', ['expected add ok', empty]);
|
|
623
|
+
const [state3, absentRead] = virtual(state2)(e.revision(absent[1]));
|
|
624
|
+
assert(absentRead[0] === 'ok', ['expected revision ok', absentRead]);
|
|
625
|
+
const [, emptyRead] = virtual(state3)(e.revision(empty[1]));
|
|
626
|
+
assert(emptyRead[0] === 'ok', ['expected revision ok', emptyRead]);
|
|
627
|
+
assertEq(absentRead[1].lock, undefined);
|
|
628
|
+
assertEq(Object.keys(emptyRead[1].lock ?? {}).length, 0);
|
|
629
|
+
},
|
|
630
|
+
revisionInvalidLockValueIsRejected: () => {
|
|
631
|
+
const c = fileCas(sha256)(home);
|
|
632
|
+
const [state0, cacheKey] = virtual(emptyState)(initEvo(c));
|
|
633
|
+
const e = evo(c)(cacheKey);
|
|
634
|
+
const [, result] = virtual(state0)(e.add({
|
|
635
|
+
parents: [], subject: 'doc', snapshot: vecToCBase32(vec8(0x42n)),
|
|
636
|
+
lock: { dependency: 'not a hash!' },
|
|
637
|
+
}));
|
|
638
|
+
assertEq(result[0], 'error');
|
|
639
|
+
},
|
|
640
|
+
equivalentLockOrdersReuseOneCasAddress: () => {
|
|
641
|
+
const c = fileCas(sha256)(home);
|
|
642
|
+
const [state0, cacheKey] = virtual(emptyState)(initEvo(c));
|
|
643
|
+
const e = evo(c)(cacheKey);
|
|
644
|
+
const a = vecToCBase32(vec8(0x1an));
|
|
645
|
+
const b = vecToCBase32(vec8(0x2bn));
|
|
646
|
+
const snapshot = vecToCBase32(vec8(0x3cn));
|
|
647
|
+
const [state1, first] = virtual(state0)(e.add({ parents: [], subject: 'doc', snapshot, lock: { '2': b, '10': a } }));
|
|
648
|
+
assert(first[0] === 'ok', ['expected add ok', first]);
|
|
649
|
+
const [, second] = virtual(state1)(e.add({ lock: { '10': a, '2': b }, snapshot, subject: 'doc', parents: [] }));
|
|
650
|
+
assert(second[0] === 'ok', ['expected add ok', second]);
|
|
651
|
+
assertEq(second[1], first[1]);
|
|
652
|
+
},
|
|
599
653
|
// A raw CAS write (e.g. `cas_add`) of valid revision content is folded
|
|
600
654
|
// into the cache exactly as `addRevision` would, without going through
|
|
601
655
|
// `evo.add` — this is what keeps `cas_add` and `evo_add` writes to the
|
|
@@ -20,6 +20,10 @@ export declare const mediaType: "application/vnd.fjs.revision+json";
|
|
|
20
20
|
* not by this schema on its own.
|
|
21
21
|
*/
|
|
22
22
|
export declare const hash: import("../../types/rtti/module.f.ts").String;
|
|
23
|
+
/** Structural schema for a Stage 1 flat lock map. */
|
|
24
|
+
declare const lock: import("../../types/rtti/module.f.ts").Type1<"record", import("../../types/rtti/module.f.ts").String>;
|
|
25
|
+
/** A flat set of subject-to-snapshot bindings supplied to dependency resolvers. */
|
|
26
|
+
export type LockMap = Ts<typeof lock>;
|
|
23
27
|
/**
|
|
24
28
|
* rtti schema for a `revision` BLOB. See the README for the full semantics of
|
|
25
29
|
* each field; `dialect` is the type discriminant, matched here as an exact
|
|
@@ -32,9 +36,12 @@ export declare const revisionSchema: {
|
|
|
32
36
|
readonly snapshot: import("../../types/rtti/module.f.ts").String;
|
|
33
37
|
readonly generation: import("../../types/rtti/module.f.ts").Number;
|
|
34
38
|
readonly archived: import("../../types/rtti/module.f.ts").Or<readonly [true, undefined]>;
|
|
39
|
+
readonly lock: import("../../types/rtti/module.f.ts").Or<readonly [import("../../types/rtti/module.f.ts").Type1<"record", import("../../types/rtti/module.f.ts").String>, undefined]>;
|
|
35
40
|
};
|
|
36
41
|
/** The TypeScript type derived from {@link revisionSchema} — the single source of truth. */
|
|
37
42
|
export type Revision = Ts<typeof revisionSchema>;
|
|
43
|
+
/** Serializes a revision canonically, recursively sorting every object's property names. */
|
|
44
|
+
export declare const encodeText: (revision: Revision) => string;
|
|
38
45
|
/** True when `s` decodes as a cbase32 CAS hash (rejects `https://` and any other non-cbase32 string). */
|
|
39
46
|
export declare const isHash: (s: string) => boolean;
|
|
40
47
|
/** Either a structural validation error or a semantic (hash / generation) error message. */
|
|
@@ -84,3 +91,4 @@ export declare const decodeText: (text: string) => Result<Revision, RevisionErro
|
|
|
84
91
|
* `snapshot` is not a cbase32 hash is not one.
|
|
85
92
|
*/
|
|
86
93
|
export declare const revisionDialect: DialectEntry;
|
|
94
|
+
export {};
|
|
@@ -14,12 +14,14 @@
|
|
|
14
14
|
*
|
|
15
15
|
* @module
|
|
16
16
|
*/
|
|
17
|
-
import { array, number, option, string } from '../../types/rtti/module.f.js';
|
|
17
|
+
import { array, number, option, record, string } from '../../types/rtti/module.f.js';
|
|
18
18
|
import { validate as rttiValidate } from '../../types/rtti/validate/module.f.js';
|
|
19
19
|
import { parse as parseJson } from '../json/module.f.js';
|
|
20
20
|
import { cBase32ToVec } from '../../basen/cbase32/module.f.js';
|
|
21
21
|
import { error, ok } from '../../types/result/module.f.js';
|
|
22
22
|
import { dialectEntry } from '../module.f.js';
|
|
23
|
+
import { definedEntries, sort } from '../../types/object/module.f.js';
|
|
24
|
+
import { stringify } from '../json/module.f.js';
|
|
23
25
|
/**
|
|
24
26
|
* Format tag: names the dialect of this BLOB. The media type it is served
|
|
25
27
|
* with is derived mechanically: `application/` + `dialect` + `+json`.
|
|
@@ -37,6 +39,8 @@ export const mediaType = `application/${dialect}+json`;
|
|
|
37
39
|
* not by this schema on its own.
|
|
38
40
|
*/
|
|
39
41
|
export const hash = string;
|
|
42
|
+
/** Structural schema for a Stage 1 flat lock map. */
|
|
43
|
+
const lock = record(string);
|
|
40
44
|
/**
|
|
41
45
|
* rtti schema for a `revision` BLOB. See the README for the full semantics of
|
|
42
46
|
* each field; `dialect` is the type discriminant, matched here as an exact
|
|
@@ -49,7 +53,10 @@ export const revisionSchema = {
|
|
|
49
53
|
snapshot: hash,
|
|
50
54
|
generation: number,
|
|
51
55
|
archived: option(true),
|
|
56
|
+
lock: option(lock),
|
|
52
57
|
};
|
|
58
|
+
/** Serializes a revision canonically, recursively sorting every object's property names. */
|
|
59
|
+
export const encodeText = stringify(sort);
|
|
53
60
|
/** Structural-only validator: checks the shape, not the hash / generation semantics. */
|
|
54
61
|
const validateShape = rttiValidate(revisionSchema);
|
|
55
62
|
/** True when `s` decodes as a cbase32 CAS hash (rejects `https://` and any other non-cbase32 string). */
|
|
@@ -89,6 +96,11 @@ export const checkReferences = (r) => {
|
|
|
89
96
|
if (!isHash(r.snapshot)) {
|
|
90
97
|
return error(`snapshot is not a valid hash: ${r.snapshot}`);
|
|
91
98
|
}
|
|
99
|
+
for (const [subject, snapshot] of definedEntries(r.lock ?? {})) {
|
|
100
|
+
if (!isHash(snapshot)) {
|
|
101
|
+
return error(`lock value for ${subject} is not a valid hash: ${snapshot}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
92
104
|
if (!Number.isSafeInteger(r.generation) || r.generation < 0) {
|
|
93
105
|
return error(`generation must be a non-negative safe integer: ${r.generation}`);
|
|
94
106
|
}
|
|
@@ -18,6 +18,12 @@ export declare const proof: {
|
|
|
18
18
|
positiveGenerationAccepted: () => void;
|
|
19
19
|
httpsRejectedInParents: () => void;
|
|
20
20
|
archivedAccepted: () => void;
|
|
21
|
+
lockAbsentAccepted: () => void;
|
|
22
|
+
emptyLockAccepted: () => void;
|
|
23
|
+
validLockAccepted: () => void;
|
|
24
|
+
malformedLockRejected: () => void;
|
|
25
|
+
invalidHashLockRejected: () => void;
|
|
26
|
+
aliasHashLockAccepted: () => void;
|
|
21
27
|
wrongDialectRejected: () => void;
|
|
22
28
|
missingSubjectRejected: () => void;
|
|
23
29
|
extraFieldsAccepted: () => void;
|
|
@@ -28,4 +34,9 @@ export declare const proof: {
|
|
|
28
34
|
malformedJsonRejected: () => void;
|
|
29
35
|
ordinaryJsonRejected: () => void;
|
|
30
36
|
};
|
|
37
|
+
encodeText: {
|
|
38
|
+
recursivelySortsObjectsLexicographically: () => void;
|
|
39
|
+
preservesArrayOrder: () => void;
|
|
40
|
+
parsedEquivalentSourcesConverge: () => void;
|
|
41
|
+
};
|
|
31
42
|
};
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { assert, assertEq } from '../../asserts/module.f.js';
|
|
2
|
-
import { dialect, mediaType, isHash, validate, decodeText } from './module.f.js';
|
|
2
|
+
import { dialect, mediaType, isHash, validate, decodeText, encodeText } from './module.f.js';
|
|
3
3
|
// Valid cbase32 hashes (round-tripped in fjs/basen/cbase32/proof.f.ts): single
|
|
4
4
|
// cbase32 symbols, cheap to write inline here.
|
|
5
5
|
const h1 = '8';
|
|
6
6
|
const h2 = 'r';
|
|
7
|
+
// `I` is accepted as an alias spelling of canonical cBase32 `1`.
|
|
8
|
+
const alias = 'I';
|
|
9
|
+
const _lockMapAllowsMissingSubjects = {};
|
|
7
10
|
// A shape-valid revision: every required field present (`snapshot` and
|
|
8
11
|
// `generation` included), with `extra` overriding or adding fields per test.
|
|
9
12
|
const revisionOf = (extra) => ({
|
|
@@ -95,6 +98,32 @@ export const proof = {
|
|
|
95
98
|
const [t] = validate(revisionOf({ archived: true }));
|
|
96
99
|
assertEq(t, 'ok');
|
|
97
100
|
},
|
|
101
|
+
lockAbsentAccepted: () => {
|
|
102
|
+
const r = validate(revisionOf({}));
|
|
103
|
+
assert(r[0] === 'ok', ['expected ok', r]);
|
|
104
|
+
assertEq(r[1].lock, undefined);
|
|
105
|
+
},
|
|
106
|
+
emptyLockAccepted: () => {
|
|
107
|
+
const r = validate(revisionOf({ lock: {} }));
|
|
108
|
+
assert(r[0] === 'ok', ['expected ok', r]);
|
|
109
|
+
assertEq(Object.keys(r[1].lock ?? {}).length, 0);
|
|
110
|
+
},
|
|
111
|
+
validLockAccepted: () => {
|
|
112
|
+
const [t] = validate(revisionOf({ lock: { dependency: h1 } }));
|
|
113
|
+
assertEq(t, 'ok');
|
|
114
|
+
},
|
|
115
|
+
malformedLockRejected: () => {
|
|
116
|
+
const [t] = validate(revisionOf({ lock: { dependency: 1 } }));
|
|
117
|
+
assertEq(t, 'error');
|
|
118
|
+
},
|
|
119
|
+
invalidHashLockRejected: () => {
|
|
120
|
+
const [t] = validate(revisionOf({ lock: { dependency: 'https://example.com/x' } }));
|
|
121
|
+
assertEq(t, 'error');
|
|
122
|
+
},
|
|
123
|
+
aliasHashLockAccepted: () => {
|
|
124
|
+
const [t] = validate(revisionOf({ lock: { dependency: alias } }));
|
|
125
|
+
assertEq(t, 'ok');
|
|
126
|
+
},
|
|
98
127
|
// Wrong dialect tag: structural validation rejects it outright.
|
|
99
128
|
wrongDialectRejected: () => {
|
|
100
129
|
const [t] = validate({ dialect: 'vnd.fjs.other', subject: h1, parents: [], snapshot: h2, generation: 0 });
|
|
@@ -137,4 +166,25 @@ export const proof = {
|
|
|
137
166
|
assertEq(t, 'error');
|
|
138
167
|
},
|
|
139
168
|
},
|
|
169
|
+
encodeText: {
|
|
170
|
+
recursivelySortsObjectsLexicographically: () => {
|
|
171
|
+
const revision = revisionOf({ lock: { '2': h2, '10': h1 } });
|
|
172
|
+
const decoded = validate(revision);
|
|
173
|
+
assert(decoded[0] === 'ok', ['expected ok', decoded]);
|
|
174
|
+
assertEq(encodeText(decoded[1]), `{"dialect":"${dialect}","generation":0,"lock":{"10":"${h1}","2":"${h2}"},"parents":[],"snapshot":"${h2}","subject":"${h1}"}`);
|
|
175
|
+
},
|
|
176
|
+
preservesArrayOrder: () => {
|
|
177
|
+
const decoded = validate(revisionOf({ parents: [h2, h1] }));
|
|
178
|
+
assert(decoded[0] === 'ok', ['expected ok', decoded]);
|
|
179
|
+
assert(encodeText(decoded[1]).includes(`"parents":["${h2}","${h1}"]`));
|
|
180
|
+
},
|
|
181
|
+
parsedEquivalentSourcesConverge: () => {
|
|
182
|
+
const a = decodeText(` { "subject":"${h1}", "snapshot":"${h2}", "parents":[], "generation":0, "dialect":"${dialect}" } `);
|
|
183
|
+
const b = decodeText(`{"dialect":"${dialect}","generation":0,"parents":[],"snapshot":"${h2}","subject":"${h1}"}`);
|
|
184
|
+
assert(a[0] === 'ok', ['expected ok', a]);
|
|
185
|
+
assert(b[0] === 'ok', ['expected ok', b]);
|
|
186
|
+
assertEq(encodeText(a[1]), encodeText(b[1]));
|
|
187
|
+
},
|
|
188
|
+
},
|
|
140
189
|
};
|
|
190
|
+
void _lockMapAllowsMissingSubjects;
|