@volter/world-core 3.0.161 → 3.0.163
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/generated/pack-facts.json +84 -5
- package/dist/inject.cjs +2 -2
- package/dist/src/derived-core.d.ts +44 -3
- package/dist/src/derived-core.js +231 -62
- package/dist/src/index.d.ts +4 -2
- package/dist/src/index.js +3 -1
- package/dist/src/machines.d.ts +4 -16
- package/dist/src/machines.js +10 -44
- package/dist/src/pack-fetch.js +29 -5
- package/dist/src/packRegistry.d.ts +19 -0
- package/dist/src/protobuf.d.ts +4 -0
- package/dist/src/protobuf.js +43 -2
- package/dist/src/request-scope.d.ts +2 -0
- package/dist/src/request-scope.js +9 -0
- package/dist/src/runtime.d.ts +6 -3
- package/dist/src/runtime.js +4 -1
- package/dist/src/serve-http.d.ts +11 -0
- package/dist/src/serve-http.js +132 -0
- package/dist/src/signing.d.ts +30 -7
- package/dist/src/signing.js +32 -16
- package/dist/src/sockets.d.ts +1 -1
- package/dist/src/sockets.js +11 -7
- package/dist/src/world-account-name.d.ts +2 -0
- package/dist/src/world-account-name.js +4 -0
- package/generated/pack-facts.json +84 -5
- package/inject.cjs +2 -2
- package/package.json +1 -1
- package/src/derived-core.ts +248 -70
- package/src/index.ts +5 -2
- package/src/machines.ts +10 -38
- package/src/pack-fetch.ts +27 -5
- package/src/packRegistry.ts +8 -2
- package/src/protobuf.ts +39 -2
- package/src/request-scope.ts +9 -0
- package/src/runtime.ts +8 -3
- package/src/serve-http.ts +111 -0
- package/src/signing.ts +38 -18
- package/src/sockets.ts +12 -9
- package/src/world-account-name.ts +4 -0
package/src/derived-core.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { storageTenant } from './request-scope.ts';
|
|
2
|
+
import { isWorldAccountName } from './world-account-name.ts';
|
|
1
3
|
import { parseExactJson, compareExactNumbers, exactInteger, isExactIntegerValue } from './exact-json.ts';
|
|
2
4
|
// THE DERIVED CORE — what a derived pack does for an operation it declares no handler for
|
|
3
5
|
// (docs/contributing/architecture.md, "Protocol 3"). The generated surface says what the
|
|
@@ -20,7 +22,7 @@ import { openSessions, socketWrite, type SocketDecl, type SocketSession } from '
|
|
|
20
22
|
import { liveAccess } from './live/access.ts';
|
|
21
23
|
import type { LiveAccess, LiveDecl } from './live/types.ts';
|
|
22
24
|
import { awaitWrite, awaitCondition, heldReadsWrite } from './held-reads.ts';
|
|
23
|
-
import { MACHINE_POOL, poolFor, guardedMachinePool, type MachinePool, type MachineHttpReply
|
|
25
|
+
import { MACHINE_POOL, poolFor, guardedMachinePool, type MachinePool, type MachineHttpReply } from './machines.ts';
|
|
24
26
|
import { askApplication, deliverEvents, type ApplicationAnswer, type ApplicationStandIn, type EventRender, type EventScheme, type EventsDecl, type EventValues } from './events.ts';
|
|
25
27
|
import type { RateBudgetDeclaration } from './rateBudget.ts';
|
|
26
28
|
import { handlerCrypto, hmac, ksuidFrom, lettersFrom, sha256, uuidFrom, type HandlerCrypto } from './signing.ts';
|
|
@@ -41,7 +43,7 @@ import type { SubjectFields } from './hash.ts';
|
|
|
41
43
|
import { twinPublicBase, withRequestScopes } from './twin-fetch.ts';
|
|
42
44
|
import { inVendorMove, isReadOnlyRequest, runAsVendorMove, writesRefused } from './request-scope.ts';
|
|
43
45
|
import type { DerivedCall, DerivedCoreOutcome, DerivedHandler, DerivedOperation } from './derived.ts';
|
|
44
|
-
import type
|
|
46
|
+
import { getPack, type PackDescriptor } from './packRegistry.ts';
|
|
45
47
|
import type { ScenarioDecision, ScenarioHandler } from './scenario.ts';
|
|
46
48
|
import { bodyTextOf, checkJsonBody, takeParsedBody } from './derived.ts';
|
|
47
49
|
|
|
@@ -524,7 +526,7 @@ export type DerivedManifest = {
|
|
|
524
526
|
* an organization, a team, an account — and a caller reaches only its own tenant's, as a parent's children are
|
|
525
527
|
* reached under it. `field` holds it on each subject; `semantics/tenant.ts` names the caller's. The core scopes every
|
|
526
528
|
* read, list, update and delete by it and stamps it on every create, for each resource not `shared`. */
|
|
527
|
-
tenant?: { field: string };
|
|
529
|
+
tenant?: { field: string; storage?: 'namespaced' };
|
|
528
530
|
/** The vendor's own lanes behind this manifest's API ("Other wires: lanes"), dispatched by the kernel before the
|
|
529
531
|
* vendor's own doors, clock and API (LanesDecl). */
|
|
530
532
|
lanes?: LanesDecl;
|
|
@@ -744,13 +746,19 @@ function notFound(manifest: DerivedManifest, object: string, id: string, param?:
|
|
|
744
746
|
|
|
745
747
|
// ── the tree ─────────────────────────────────────────────────────────────────────────────────
|
|
746
748
|
|
|
747
|
-
|
|
749
|
+
const TENANT_TYPE = '::account:';
|
|
750
|
+
const accountScope = (m: DerivedManifest): string | undefined => m.tenant?.storage === 'namespaced' ? storageTenant(m.service) : undefined;
|
|
751
|
+
const scopeLabel = (m: DerivedManifest, label: string): string => { const account = accountScope(m); return account && account !== 'default' ? `account:${account}:${label}` : label; };
|
|
752
|
+
export const storedType = (m: DerivedManifest, resource: string): string => {
|
|
753
|
+
const base = m.resources[resource]?.storedAs ?? resource, account = accountScope(m);
|
|
754
|
+
return account && account !== 'default' && !m.resources[resource]?.shared ? `${base}${TENANT_TYPE}${encodeURIComponent(account)}` : base;
|
|
755
|
+
};
|
|
748
756
|
|
|
749
757
|
// A service's resources by type, folded once per World state (the tree's stamp; any write moves it), not on every read:
|
|
750
758
|
// a read of one resource type projected the service's whole tree, so a pack whose tree also holds many subjects of
|
|
751
759
|
// other types (PlanetScale's SQL rows beside its management records) paid for all of them on each read. A read gets its
|
|
752
760
|
// own copy of each resource, as twinResources hands out, with the vendor's own id/type/updatedAt a spread would drop.
|
|
753
|
-
const typeIndexes = new WeakMap<object, Map<string, { stamp: string; byType: Map<string, Map<string, TwinResource
|
|
761
|
+
const typeIndexes = new WeakMap<object, Map<string, { stamp: string; byType: Map<string, Map<string, TwinResource>>; typeByKey: Map<string, string> }>>();
|
|
754
762
|
/** A service's resources of one type, each the reader's own copy: `twinResources` filtered by type, from an index
|
|
755
763
|
* that follows each write rather than projecting the whole tree per call. */
|
|
756
764
|
export function resourcesOfType(service: string, type: string, root?: string): TwinResource[] {
|
|
@@ -764,8 +772,13 @@ export function resourcesOfType(service: string, type: string, root?: string): T
|
|
|
764
772
|
if (held && delta) {
|
|
765
773
|
// the index keeps the tree's order: a subject updated where it stands stays there, one new to the tree (or made
|
|
766
774
|
// again after a delete) goes to the end in the order the tree holds it
|
|
767
|
-
for (const k of [...delta.removed, ...delta.appended])
|
|
775
|
+
for (const k of [...delta.removed, ...delta.appended]) {
|
|
776
|
+
const type = held.typeByKey.get(k);
|
|
777
|
+
if (type !== undefined) held.byType.get(type)?.delete(k);
|
|
778
|
+
held.typeByKey.delete(k);
|
|
779
|
+
}
|
|
768
780
|
const byKey = new Map(delta.changed.map((r) => [`${r.type}:${r.id}`, r]));
|
|
781
|
+
for (const [k, r] of byKey) held.typeByKey.set(k, r.type);
|
|
769
782
|
for (const [k, r] of byKey) if (!delta.appended.includes(k)) (held.byType.get(r.type) ?? held.byType.set(r.type, new Map()).get(r.type)!).set(k, r);
|
|
770
783
|
for (const k of delta.appended) { const r = byKey.get(k)!; (held.byType.get(r.type) ?? held.byType.set(r.type, new Map()).get(r.type)!).set(k, r); }
|
|
771
784
|
held.stamp = delta.stamp;
|
|
@@ -775,7 +788,7 @@ export function resourcesOfType(service: string, type: string, root?: string): T
|
|
|
775
788
|
if (!held || held.stamp !== stamp) {
|
|
776
789
|
const byType = new Map<string, Map<string, TwinResource>>();
|
|
777
790
|
for (const r of twinResources(service, root)) (byType.get(r.type) ?? byType.set(r.type, new Map()).get(r.type)!).set(`${r.type}:${r.id}`, r);
|
|
778
|
-
held = { stamp, byType };
|
|
791
|
+
held = { stamp, byType, typeByKey: new Map([...byType].flatMap(([type, rows]) => [...rows.keys()].map(key => [key, type] as const))) };
|
|
779
792
|
indexes.set(key, held);
|
|
780
793
|
}
|
|
781
794
|
}
|
|
@@ -785,7 +798,9 @@ export function resourcesOfType(service: string, type: string, root?: string): T
|
|
|
785
798
|
function stored(m: DerivedManifest, resource: string, root?: string, opts: { withDeleted?: boolean } = {}): TwinResource[] {
|
|
786
799
|
const type = storedType(m, resource);
|
|
787
800
|
const when = Object.entries(m.resources[resource]?.readableWhen ?? {});
|
|
788
|
-
|
|
801
|
+
const rows = m.tenant?.storage === 'namespaced' && accountScope(m) === undefined && !m.resources[resource]?.shared
|
|
802
|
+
? twinResources(m.service, root).filter(row => row.type === type || row.type.startsWith(`${type}${TENANT_TYPE}`)) : resourcesOfType(m.service, type, root);
|
|
803
|
+
return rows.filter((r) => (opts.withDeleted || r.deleted !== true) && when.every(([k, v]) => (r as Record<string, unknown>)[k] === v));
|
|
789
804
|
}
|
|
790
805
|
|
|
791
806
|
/** The subject an item operation names: the manifest's key over the path parameters, or the last one. */
|
|
@@ -821,7 +836,7 @@ function missingParent(m: DerivedManifest, resource: string, call: DerivedCall,
|
|
|
821
836
|
const id = parentValue(parent, call.params);
|
|
822
837
|
const where = Object.entries(parent.where ?? {}).map(([field, template]) => [field, String(fill(template, call.params))] as const);
|
|
823
838
|
const found = (r: TwinResource): boolean => (where.length ? where.every(([field, v]) => String(parentField(r, field)) === v) : r.id === id)
|
|
824
|
-
&& (!m.tenant || m.resources[parent.resource]?.shared || (r as Record<string, unknown>)[m.tenant.field] === tenant);
|
|
839
|
+
&& (!m.tenant || m.tenant.storage === 'namespaced' || m.resources[parent.resource]?.shared || (r as Record<string, unknown>)[m.tenant.field] === tenant);
|
|
825
840
|
return id !== undefined && stored(m, parent.resource, root, { withDeleted: parent.allowDeleted === true }).some(found) ? undefined : notFound(m, parent.resource, String(id), parent.param ?? parent.params?.at(-1));
|
|
826
841
|
}
|
|
827
842
|
|
|
@@ -953,15 +968,90 @@ function worldSeed(service: string, root: string | undefined, at: string): Promi
|
|
|
953
968
|
const pending = seeding.get(key);
|
|
954
969
|
if (pending) return pending;
|
|
955
970
|
const made = (async () => {
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
971
|
+
// The action lock also joins first asks from several renderers/processes to the same seed.
|
|
972
|
+
const { value } = await runAsVendorMove(() => applyTwinWriteAtomic(service, snapshot => {
|
|
973
|
+
const existing = snapshot.find(row => row.type === SEED && row.id === 'seed');
|
|
974
|
+
if (typeof existing?.value === 'string') return { kind: 'skip', value: existing.value };
|
|
975
|
+
const value = Buffer.from(globalThis.crypto.getRandomValues(new Uint8Array(32))).toString('hex');
|
|
976
|
+
return { kind: 'write', value, write: { operation: 'world_seed.record', subjectType: SEED, subjectId: 'seed', fields: { value }, occurredAt: at, actor: { kind: 'system' } } };
|
|
977
|
+
}, root));
|
|
959
978
|
return value;
|
|
960
979
|
})().finally(() => seeding.delete(key));
|
|
961
980
|
seeding.set(key, made);
|
|
962
981
|
return made;
|
|
963
982
|
}
|
|
964
983
|
|
|
984
|
+
/** World-owned generated values use the same private seed and log as pack secrets. */
|
|
985
|
+
export async function worldSecret(service: string, root: string, label: string, at = new Date().toISOString()): Promise<string> {
|
|
986
|
+
return hmac(await worldSeed(service, root, at), label, 'base64url');
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
/** The same retained account primitive for pack doors and private native twin fronts. */
|
|
990
|
+
export async function worldAccount(service: string, root: string | undefined, id: unknown = 'default', at = worldNow(), provisioning = false): Promise<string> {
|
|
991
|
+
if (!isWorldAccountName(id)) throw new Error('Credential account must be a name of at most 128 letters, digits, dots, underscores or hyphens');
|
|
992
|
+
await applyTwinWriteAtomic(service, (snapshot) => {
|
|
993
|
+
const held = snapshot.find(row => row.type === '_world_account' && row.id === id);
|
|
994
|
+
return held ? { kind: 'skip', value: id } : { kind: 'write', value: id, write: { operation: 'world_account.create', subjectType: '_world_account', subjectId: id, fields: { name: id }, ...(provisioning ? { input: { activity: 'provisioning' } } : {}), occurredAt: at, actor: { kind: 'system' } } };
|
|
995
|
+
}, root);
|
|
996
|
+
return id;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
type WorldOrganization = { id: string; label: string; members: string[] };
|
|
1000
|
+
type VendorNameRule = { max: number; lower?: boolean; allowed: RegExp; separator?: string; first?: RegExp };
|
|
1001
|
+
type NativeAccountLink = { resource: string; id: string };
|
|
1002
|
+
|
|
1003
|
+
function nativeAccountLink(row: TwinResource | undefined, field?: string): NativeAccountLink | undefined {
|
|
1004
|
+
if (!row || row.deleted === true) return undefined;
|
|
1005
|
+
const fields = ownFields(row);
|
|
1006
|
+
const value = field === undefined ? fields : fields[field];
|
|
1007
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
|
1008
|
+
const link = value as Record<string, unknown>;
|
|
1009
|
+
return typeof link.resource === 'string' && typeof link.id === 'string' ? { resource: link.resource, id: link.id } : undefined;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
const sameAccountLink = (a: NativeAccountLink | undefined, b: NativeAccountLink | undefined): boolean =>
|
|
1013
|
+
a?.resource === b?.resource && a?.id === b?.id;
|
|
1014
|
+
|
|
1015
|
+
/** Native creation and reconstruction run outside the action lock; only the retained link is compared and written. */
|
|
1016
|
+
async function retainNativeAccountLink<T>(
|
|
1017
|
+
service: string, root: string | undefined, at: string, call: DerivedCall,
|
|
1018
|
+
type: string, id: string, field: string | undefined, resource: string,
|
|
1019
|
+
make: () => Promise<string>, read: (link: NativeAccountLink) => Promise<T | undefined>,
|
|
1020
|
+
): Promise<T> {
|
|
1021
|
+
for (;;) {
|
|
1022
|
+
const before = nativeAccountLink(resourcesOfType(service, type, root).find((row) => row.id === id), field);
|
|
1023
|
+
const held = before && await read(before);
|
|
1024
|
+
if (held !== undefined) return held;
|
|
1025
|
+
const made = { resource, id: await make() };
|
|
1026
|
+
if (await read(made) === undefined) throw new Error(`Credential ${type}/${id}: native ${resource}/${made.id} is unavailable after creation`);
|
|
1027
|
+
const { value } = await applyTwinWriteAtomic(service, (snapshot) => {
|
|
1028
|
+
const current = nativeAccountLink(snapshot.find((row) => row.type === type && row.id === id), field);
|
|
1029
|
+
if (!sameAccountLink(current, before)) return { kind: 'skip', value: current };
|
|
1030
|
+
return {
|
|
1031
|
+
kind: 'write', value: made,
|
|
1032
|
+
write: {
|
|
1033
|
+
operation: `${type.slice(1)}.link`, subjectType: type, subjectId: id,
|
|
1034
|
+
fields: field === undefined ? made : { name: id, [field]: made },
|
|
1035
|
+
...provisioningInput(call), occurredAt: at, actor: { kind: 'system' },
|
|
1036
|
+
},
|
|
1037
|
+
};
|
|
1038
|
+
}, root);
|
|
1039
|
+
const linked = value && await read(value);
|
|
1040
|
+
if (linked !== undefined) return linked;
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
function vendorName(label: string, rule: VendorNameRule, { suffixed = false, identity = label }: { suffixed?: boolean; identity?: string } = {}): string {
|
|
1045
|
+
const allowed = new RegExp(rule.allowed.source, rule.allowed.flags.replace(/[gy]/g, ''));
|
|
1046
|
+
const separator = rule.separator ?? '-';
|
|
1047
|
+
if ([...separator].length !== 1 || !allowed.test(separator)) throw new Error('Credential name separator must be one allowed character');
|
|
1048
|
+
const first = rule.first && new RegExp(rule.first.source, rule.first.flags.replace(/[gy]/g, ''));
|
|
1049
|
+
let name = [...(rule.lower ? label.toLowerCase() : label)].map((letter) => allowed.test(letter) ? letter : separator).join('');
|
|
1050
|
+
name = name.replace(new RegExp(`${escapeRe(separator)}+`, 'g'), separator);
|
|
1051
|
+
if (first && !first.test(name[0] ?? '')) name = `w${separator}${name}`;
|
|
1052
|
+
return name !== label || name.length > rule.max || suffixed ? `${name.slice(0, Math.max(0, rule.max - 9))}${separator}${sha256(identity).slice(0, 8)}` : name;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
965
1055
|
/** A World's signing key for a label (ctx.signingKey): made once from the platform's random source, then read; the
|
|
966
1056
|
* making is one per service store and label in the process, as the seed's is. */
|
|
967
1057
|
const SIGNING_KEY = '_world_signing_key';
|
|
@@ -995,7 +1085,13 @@ function worldSigningKey(service: string, root: string | undefined, at: string,
|
|
|
995
1085
|
return made;
|
|
996
1086
|
}
|
|
997
1087
|
|
|
998
|
-
|
|
1088
|
+
/** Vendor ids remain unique across its accounts, including public links that carry an id instead of a key. */
|
|
1089
|
+
function mintRows(m: DerivedManifest, resource: string, root?: string, snapshot?: readonly TwinResource[]): readonly TwinResource[] {
|
|
1090
|
+
const base = m.resources[resource]?.storedAs ?? resource;
|
|
1091
|
+
if (m.tenant?.storage === 'namespaced') return (snapshot ?? twinResources(m.service, root)).filter(row => row.type === base || row.type.startsWith(`${base}${TENANT_TYPE}`));
|
|
1092
|
+
return snapshot ? snapshot.filter(row => row.type === base) : resourcesOfType(m.service, base, root);
|
|
1093
|
+
}
|
|
1094
|
+
function mintId(m: DerivedManifest, resource: string, root: string | undefined, at: string, snapshot?: readonly TwinResource[]): string {
|
|
999
1095
|
if (!m.resources[resource]) throw new Error(`mint: ${resource} is not a resource ${m.vendor}'s manifest declares`);
|
|
1000
1096
|
const prefix = m.resources[resource]!.idPrefix;
|
|
1001
1097
|
const template = m.resources[resource]!.ids ?? m.ids.template;
|
|
@@ -1012,7 +1108,7 @@ function mintId(m: DerivedManifest, resource: string, root?: string, at?: string
|
|
|
1012
1108
|
if (digits) {
|
|
1013
1109
|
const width = Number(digits[1]);
|
|
1014
1110
|
if (!Number.isInteger(width) || width < 1 || width > 77) throw new Error('mint: decimal width must be between 1 and 77');
|
|
1015
|
-
const held = new Set((
|
|
1111
|
+
const held = new Set(mintRows(m, resource, root, snapshot).map((r) => r.id));
|
|
1016
1112
|
const floor = 10n ** BigInt(width - 1);
|
|
1017
1113
|
for (let n = held.size + 1; ; n++) {
|
|
1018
1114
|
const id = String(floor + BigInt('0x' + sha256(`${m.service}:${resource}:${n}`)) % (9n * floor));
|
|
@@ -1031,7 +1127,7 @@ function mintId(m: DerivedManifest, resource: string, root?: string, at?: string
|
|
|
1031
1127
|
const pattern = new RegExp(`^${escapeRe(String(fill(head!, { prefix })))}(\\d+)${escapeRe(String(fill(tail, { prefix })))}$`);
|
|
1032
1128
|
let max = 0n;
|
|
1033
1129
|
// the one type, from the index that follows writes (a scan of the whole World per create grew with it)
|
|
1034
|
-
for (const r of (
|
|
1130
|
+
for (const r of mintRows(m, resource, root, snapshot)) {
|
|
1035
1131
|
const hit = pattern.exec(r.id);
|
|
1036
1132
|
if (hit && BigInt(hit[1]!) > max) max = BigInt(hit[1]!);
|
|
1037
1133
|
}
|
|
@@ -1045,13 +1141,13 @@ function mintId(m: DerivedManifest, resource: string, root?: string, at?: string
|
|
|
1045
1141
|
/** The last snowflake minted per vendor and World in this process: two mints before the first is written (a message
|
|
1046
1142
|
* and its attachment) must not meet. */
|
|
1047
1143
|
const lastSnowflake = new Map<string, bigint>();
|
|
1048
|
-
function mintSnowflake(m: DerivedManifest, epoch: bigint, root
|
|
1049
|
-
const ms = BigInt(Date.parse(at
|
|
1144
|
+
function mintSnowflake(m: DerivedManifest, epoch: bigint, root: string | undefined, at: string, snapshot?: readonly TwinResource[]): string {
|
|
1145
|
+
const ms = BigInt(Date.parse(at) || 0);
|
|
1050
1146
|
const base = ms > epoch ? (ms - epoch) << 22n : 0n;
|
|
1051
1147
|
let max = -1n;
|
|
1052
1148
|
for (const [name, decl] of Object.entries(m.resources)) {
|
|
1053
1149
|
if (!/^\{snowflake:\d+\}$/.test(decl.ids ?? m.ids.template)) continue;
|
|
1054
|
-
for (const r of (
|
|
1150
|
+
for (const r of mintRows(m, name, root, snapshot)) if (/^\d+$/.test(r.id)) { const v = BigInt(r.id); if (v > max) max = v; }
|
|
1055
1151
|
}
|
|
1056
1152
|
const key = `${m.service}\0${root ?? ''}`;
|
|
1057
1153
|
const last = lastSnowflake.get(key) ?? -1n;
|
|
@@ -1063,9 +1159,9 @@ function mintSnowflake(m: DerivedManifest, epoch: bigint, root?: string, at?: st
|
|
|
1063
1159
|
|
|
1064
1160
|
/** The next KSUID for a resource (Clerk's and Svix's ids, `ksuidFrom`): the World's instant, then the type's mint
|
|
1065
1161
|
* count, so a frozen clock still lists them as made; past any the World already holds. */
|
|
1066
|
-
function mintKsuid(m: DerivedManifest, resource: string, at: string
|
|
1067
|
-
const held = new Set((
|
|
1068
|
-
const seconds = Math.floor((Date.parse(at
|
|
1162
|
+
function mintKsuid(m: DerivedManifest, resource: string, at: string, root: string | undefined, prefix: string, snapshot: readonly TwinResource[] | undefined, width: number): string {
|
|
1163
|
+
const held = new Set(mintRows(m, resource, root, snapshot).map((r) => r.id));
|
|
1164
|
+
const seconds = Math.floor((Date.parse(at) || 0) / 1000);
|
|
1069
1165
|
for (let n = held.size + 1; ; n++) {
|
|
1070
1166
|
const id = `${prefix}${ksuidFrom(seconds, n, `${m.service}:${resource}:${n}`, width)}`;
|
|
1071
1167
|
if (!held.has(id)) return id;
|
|
@@ -1074,8 +1170,8 @@ function mintKsuid(m: DerivedManifest, resource: string, at: string | undefined,
|
|
|
1074
1170
|
|
|
1075
1171
|
/** The next id of `count` lowercase letters for a resource, derived from the resource and the count before it, past
|
|
1076
1172
|
* any the World already holds. */
|
|
1077
|
-
function mintLetters(m: DerivedManifest, resource: string, count: number, root
|
|
1078
|
-
const held = new Set((
|
|
1173
|
+
function mintLetters(m: DerivedManifest, resource: string, count: number, root: string | undefined, prefix: string, snapshot?: readonly TwinResource[]): string {
|
|
1174
|
+
const held = new Set(mintRows(m, resource, root, snapshot).map((r) => r.id));
|
|
1079
1175
|
for (let n = held.size + 1; ; n++) {
|
|
1080
1176
|
const id = `${prefix}${lettersFrom(`${m.service}:${resource}:${n}`, count)}`;
|
|
1081
1177
|
if (!held.has(id)) return id;
|
|
@@ -1084,8 +1180,8 @@ function mintLetters(m: DerivedManifest, resource: string, count: number, root?:
|
|
|
1084
1180
|
|
|
1085
1181
|
/** The next hex id for a resource: N hex digits derived from the resource and the count before it, past any the World
|
|
1086
1182
|
* already holds (a Cloudflare id, 32). */
|
|
1087
|
-
function mintHex(m: DerivedManifest, resource: string, count: number, root
|
|
1088
|
-
const held = new Set((
|
|
1183
|
+
function mintHex(m: DerivedManifest, resource: string, count: number, root: string | undefined, prefix: string, snapshot?: readonly TwinResource[]): string {
|
|
1184
|
+
const held = new Set(mintRows(m, resource, root, snapshot).map((r) => r.id));
|
|
1089
1185
|
for (let n = held.size + 1; ; n++) {
|
|
1090
1186
|
const id = `${prefix}${sha256(`${m.service}:${resource}:${n}`).slice(0, count)}`;
|
|
1091
1187
|
if (!held.has(id)) return id;
|
|
@@ -1095,8 +1191,7 @@ function mintHex(m: DerivedManifest, resource: string, count: number, root?: str
|
|
|
1095
1191
|
/** The next UUID for a resource: a version-4-shaped UUID derived from the resource and the count before it, past any
|
|
1096
1192
|
* the World already holds. */
|
|
1097
1193
|
function mintUuid(m: DerivedManifest, resource: string, root?: string, snapshot?: readonly TwinResource[]): string {
|
|
1098
|
-
const
|
|
1099
|
-
const held = new Set((snapshot ? snapshot.filter((r) => r.type === type) : resourcesOfType(m.service, type, root)).map((r) => r.id));
|
|
1194
|
+
const held = new Set(mintRows(m, resource, root, snapshot).map((r) => r.id));
|
|
1100
1195
|
for (let n = held.size + 1; ; n++) {
|
|
1101
1196
|
const id = uuidFrom(`${m.service}:${resource}:${n}`);
|
|
1102
1197
|
if (!held.has(id)) return id;
|
|
@@ -1352,13 +1447,14 @@ async function finishWrite(m: DerivedManifest, call: DerivedCall, resource: stri
|
|
|
1352
1447
|
// the managed database), at the write's moment
|
|
1353
1448
|
const request = requestOfCall.get(call) ?? call.request;
|
|
1354
1449
|
const context = (): Promise<SemanticsContext> => contextFor(m, { ...call, request: request.clone() }, { ...(scopeOfCall.get(call) ?? {}), ...(root !== undefined ? { root } : {}), clock: () => occurredAt });
|
|
1355
|
-
if (m.onWrite) await m.onWrite({ operation, storedType:
|
|
1450
|
+
if (m.onWrite) await m.onWrite({ operation, storedType: m.resources[resource]?.storedAs ?? resource, body, ...(root !== undefined ? { root } : {}), occurredAt, request: request.clone(), context });
|
|
1356
1451
|
if (m.events) {
|
|
1357
|
-
const
|
|
1358
|
-
const
|
|
1452
|
+
const endpoint = (kind: import('./events.ts').EndpointsDecl): import('./events.ts').EndpointsDecl => ({ ...kind, storedAs: storedType(m, kind.storedAs), ...(kind.joins ? { joins: Object.fromEntries(Object.entries(kind.joins).map(([name, join]) => [name, { ...join, storedAs: storedType(m, join.storedAs) }])) } : {}) });
|
|
1453
|
+
const events = { ...m.events, ...(m.events.store ? { store: storedType(m, m.events.store) } : {}), endpoints: 'storedAs' in m.events.endpoints ? endpoint(m.events.endpoints) : m.events.endpoints.map(endpoint) };
|
|
1454
|
+
const write = { operation, storedType: m.resources[resource]?.storedAs ?? resource, body, ...(root !== undefined ? { root } : {}), occurredAt, request: request.clone() };
|
|
1359
1455
|
await deliverEvents(m.service, events, write, async (type) => (events.render ? events.render(await context(), write, type) : body), undefined, events.values ? async (type) => events.values!(await context(), write, type) : undefined);
|
|
1360
1456
|
}
|
|
1361
|
-
if (m.sockets?.length) socketWrite(m.service, root, { operation, storedType:
|
|
1457
|
+
if (m.sockets?.length) socketWrite(m.service, root, { operation, storedType: m.resources[resource]?.storedAs ?? resource, body, ...(root !== undefined ? { root } : {}), occurredAt, request: request.clone() });
|
|
1362
1458
|
// a read held until the World writes (held-reads.ts) hears it
|
|
1363
1459
|
heldReadsWrite(m.service, root, storedType(m, resource));
|
|
1364
1460
|
// the subject's id as it stands after the write: the vendor's, when a live head minted one
|
|
@@ -1517,21 +1613,21 @@ function adoptedPath(m: DerivedManifest, call: DerivedCall, root?: string): Deri
|
|
|
1517
1613
|
/** A call's fields with each declared reference that names an adopted subject by its local id moved to the adopted
|
|
1518
1614
|
* id. A call (a handler's operation) need not say which subject type it writes, so every reference the pack declares
|
|
1519
1615
|
* is tried: a field is rewritten only when its value is a local id the alias map holds for the reference's target. */
|
|
1520
|
-
function adoptedInCall(
|
|
1521
|
-
const refs = packReferences(service);
|
|
1616
|
+
function adoptedInCall(m: DerivedManifest, fields: Record<string, unknown>, root?: string): Record<string, unknown> {
|
|
1617
|
+
const service = m.service, refs = packReferences(service);
|
|
1522
1618
|
if (refs.length === 0) return fields;
|
|
1523
1619
|
const aliases = aliasesNow(service, root);
|
|
1524
1620
|
if (aliases.size === 0) return fields;
|
|
1525
1621
|
let out = fields;
|
|
1526
1622
|
for (const ref of refs) {
|
|
1527
1623
|
const local = ref.key(out as SubjectFields);
|
|
1528
|
-
const adopted = local === undefined ? undefined : aliases.get(`${ref.to}:${local}`);
|
|
1624
|
+
const adopted = local === undefined ? undefined : aliases.get(`${storedType(m, ref.to)}:${local}`);
|
|
1529
1625
|
if (adopted !== undefined && adopted !== local) out = { ...out, ...ref.adopt(out as SubjectFields, adopted) };
|
|
1530
1626
|
}
|
|
1531
1627
|
return out;
|
|
1532
1628
|
}
|
|
1533
1629
|
async function boundaryParams(m: DerivedManifest, call: DerivedCall, root?: string): Promise<Record<string, unknown>> {
|
|
1534
|
-
return adoptedInCall(m
|
|
1630
|
+
return adoptedInCall(m, await readParams(m, call.request, call.operation), root);
|
|
1535
1631
|
}
|
|
1536
1632
|
|
|
1537
1633
|
/** The refusal a subject's fields would draw from its resource's `unique` rules: another live subject (not `except`)
|
|
@@ -1726,7 +1822,7 @@ export async function serveCore(m: DerivedManifest, call: DerivedCall, scope: Co
|
|
|
1726
1822
|
const parent = parentOfCall(decl, call);
|
|
1727
1823
|
const parentId = parent ? parentValue(parent, call.params) : undefined;
|
|
1728
1824
|
// the caller's tenant, when the vendor has tenancy and the resource is not shared by every tenant
|
|
1729
|
-
const tenantField = m.tenant && !decl.shared ? m.tenant.field : undefined;
|
|
1825
|
+
const tenantField = m.tenant?.storage !== 'namespaced' && m.tenant && !decl.shared ? m.tenant.field : undefined;
|
|
1730
1826
|
const tenant = tenantField && scope.tenant ? await scope.tenant(await contextFor(m, { ...call, request: call.request.clone() }, scope)) : undefined;
|
|
1731
1827
|
if (tenantField && tenant === undefined) return { unmodeled: `the caller names no tenant (semantics/tenant.ts) for ${resource}` };
|
|
1732
1828
|
const orphan = missingParent(m, resource, call, root, tenant);
|
|
@@ -1917,6 +2013,20 @@ export type SemanticsContext = {
|
|
|
1917
2013
|
* random, and keeps as bookkeeping no door answers. The same label gives the same secret for the World's whole life,
|
|
1918
2014
|
* and no one can compute it from ids the World shows, as they can a value derived from them alone. */
|
|
1919
2015
|
secret(label: string): Promise<string>;
|
|
2016
|
+
/** Select an account at a pack's credential door. Packs retain their own vendor account/key semantics. */
|
|
2017
|
+
account(field?: string): Promise<string>;
|
|
2018
|
+
/** The selected organization's label and people from the World's organization registry. */
|
|
2019
|
+
organization(): Promise<WorldOrganization>;
|
|
2020
|
+
/** The selected deployment, or an empty string for the organization's own level. */
|
|
2021
|
+
deployment(): string;
|
|
2022
|
+
/** A vendor-safe label; retained links keep identity independent of names. */
|
|
2023
|
+
vendorName(label: string, rule: VendorNameRule, options?: { suffixed?: boolean; identity?: string }): string;
|
|
2024
|
+
/** Retain the native customer tenant for the selected organization. */
|
|
2025
|
+
tenant(resource: string, make: (organization: WorldOrganization) => Promise<string>): Promise<Record<string, unknown>>;
|
|
2026
|
+
/** Retain one native resource of this kind for the selected organization and deployment. */
|
|
2027
|
+
deploymentObject(kind: string, resource: string, make: (deployment: string) => Promise<string>): Promise<Record<string, unknown>>;
|
|
2028
|
+
/** Reconstruct a live native key, replacing its link when the vendor's reader refuses it. */
|
|
2029
|
+
accountKey(purpose: string, resource: string, key: { make: () => Promise<string>; read: (row: Record<string, unknown>) => Promise<string | undefined> }): Promise<{ id: string; secret: string }>;
|
|
1920
2030
|
/** The World's machine pool (machines.ts), as the pack's `machine-pool` door enrolled it; `none` when none was. */
|
|
1921
2031
|
machines: MachinePool;
|
|
1922
2032
|
/** A signing key pair for `label` (an issuer's ID tokens, a provider's JWTs, a CA's or a log's key), PEM: RSA 2048
|
|
@@ -1930,7 +2040,7 @@ export type SemanticsContext = {
|
|
|
1930
2040
|
/** Allocate a stored occurrence and append its fields under one action lock. The optional field factory is pure. */
|
|
1931
2041
|
create(resource: string, fields: Record<string, unknown> | ((id: string) => Record<string, unknown>), operation: string): Promise<Record<string, unknown>>;
|
|
1932
2042
|
/** Create under declared unique keys and the action lock; a collision returns the existing own-field view without a write. */
|
|
1933
|
-
createUnique(resource: string, fields: Record<string, unknown> | ((id: string) => Record<string, unknown>), operation: string, options?: { input
|
|
2043
|
+
createUnique(resource: string, fields: Record<string, unknown> | ((id: string) => Record<string, unknown>), operation: string, options?: { input?: 'omit'; id?: string }): Promise<{ created: boolean; row: Record<string, unknown> }>;
|
|
1934
2044
|
/** Decide an update from the current live stored row under the action lock; undefined leaves it unchanged. */
|
|
1935
2045
|
change(resource: string, id: string, decide: (row: Record<string, unknown>) => Record<string, unknown> | undefined, operation: string, options?: { input: 'omit' }): Promise<Record<string, unknown> | undefined>;
|
|
1936
2046
|
write(resource: string, id: string, fields: Record<string, unknown>, operation: string): Promise<Record<string, unknown>>;
|
|
@@ -2063,7 +2173,7 @@ export type HandlerContext = Omit<SemanticsContext, 'now' | 'core' | 'atomically
|
|
|
2063
2173
|
};
|
|
2064
2174
|
/** What a write hook reads: the writing call's context without its ways to write, so a hook that renders an event
|
|
2065
2175
|
* cannot write again (and run itself again). */
|
|
2066
|
-
export type WriteHookContext = Omit<HandlerContext, 'write' | 'batch' | 'create' | 'createUnique' | 'change' | 'issue' | 'record' | 'accumulate' | 'at' | 'over'>;
|
|
2176
|
+
export type WriteHookContext = Omit<HandlerContext, 'write' | 'batch' | 'create' | 'createUnique' | 'change' | 'issue' | 'record' | 'accumulate' | 'tenant' | 'deploymentObject' | 'accountKey' | 'at' | 'over'>;
|
|
2067
2177
|
/** A derived pack's handler: one operation, by its operationId, over the contract's context. */
|
|
2068
2178
|
export type Handler = (ctx: HandlerContext) => Promise<Response>;
|
|
2069
2179
|
|
|
@@ -2098,7 +2208,7 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
|
|
|
2098
2208
|
const raw = !parts && typeof body === 'string' && body !== '' ? await rawBody(call.request) : undefined;
|
|
2099
2209
|
sentOf.set(call, { ...sentRequest(m, asAsked, parts ? Object.fromEntries(parts.filter((p) => p.filename === null).map((p) => [p.name, new TextDecoder().decode(p.body)])) : raw ?? body), ...(parts ? { parts } : {}), ...(raw ? { type: call.request.headers.get('content-type') ?? undefined } : {}) });
|
|
2100
2210
|
// a handler reading the body itself sees the adopted id too (the boundary's one resolution, below)
|
|
2101
|
-
if (body && typeof body === 'object' && !Array.isArray(body)) body = adoptedInCall(m
|
|
2211
|
+
if (body && typeof body === 'object' && !Array.isArray(body)) body = adoptedInCall(m, body as Record<string, unknown>, root);
|
|
2102
2212
|
// every read of the body is of a copy, so the handler's own `call.request` and the core's answer (`core()`) each
|
|
2103
2213
|
// still find it unread, whichever of them reads it first
|
|
2104
2214
|
const pristine = call.request.clone();
|
|
@@ -2111,12 +2221,25 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
|
|
|
2111
2221
|
});
|
|
2112
2222
|
const paths = expandPaths(m, params);
|
|
2113
2223
|
const stateChecks: StateChecks = new Map();
|
|
2224
|
+
const credentialDoor = m.descriptor?.credentialDoor ?? getPack(m.vendor)?.credentialDoor;
|
|
2225
|
+
const credentialFields = body && typeof body === 'object' && !Array.isArray(body) ? body as Record<string, unknown> : {};
|
|
2114
2226
|
const rawResources = new WeakMap<Record<string, unknown>, string>();
|
|
2115
2227
|
const rawRow = (resource: string, row: TwinResource): Record<string, unknown> => {
|
|
2116
2228
|
const name = m.resources[resource] ? resource : Object.keys(m.resources).find((name) => storedType(m, name) === row.type);
|
|
2229
|
+
if (m.tenant?.storage === 'namespaced') {
|
|
2230
|
+
const [base, encoded] = row.type.split(TENANT_TYPE);
|
|
2231
|
+
const owner = m.resources[name ?? resource]?.shared && row[m.tenant.field] !== undefined ? row[m.tenant.field] : encoded ? decodeURIComponent(encoded) : 'default';
|
|
2232
|
+
row = copyResource(row);
|
|
2233
|
+
row.type = base!;
|
|
2234
|
+
row[m.tenant.field] = owner;
|
|
2235
|
+
}
|
|
2117
2236
|
rawResources.set(row as Record<string, unknown>, name ?? resource);
|
|
2118
2237
|
return row as Record<string, unknown>;
|
|
2119
2238
|
};
|
|
2239
|
+
const liveNativeRow = (link: NativeAccountLink): Record<string, unknown> | undefined => {
|
|
2240
|
+
const row = context.row(link.resource, link.id);
|
|
2241
|
+
return row && row.deleted !== true ? row : undefined;
|
|
2242
|
+
};
|
|
2120
2243
|
const context: SemanticsContext = {
|
|
2121
2244
|
call,
|
|
2122
2245
|
params,
|
|
@@ -2131,7 +2254,7 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
|
|
|
2131
2254
|
asVendor: (fn) => runAsVendorMove(fn),
|
|
2132
2255
|
git: (name) => {
|
|
2133
2256
|
if (!/^[A-Za-z0-9_.-]+(\/[A-Za-z0-9_.-]+)*$/.test(name) || name.split('/').some((seg) => seg === '.' || seg === '..')) throw new Error(`semantics: ${name} is not a git repository name`);
|
|
2134
|
-
const plane = `${worldPaths(m.service, root).dir}/git`;
|
|
2257
|
+
const plane = `${worldPaths(m.service, root).dir}/git${accountScope(m) && accountScope(m) !== 'default' ? `/accounts/${encodeURIComponent(accountScope(m)!)}` : ''}`;
|
|
2135
2258
|
return { store: new GitObjectStore(getActiveBlobStore(), `${plane}/repos/${name}`), refs: new GitRefs(getActiveWorldStore(), `${plane}/repos/${name}`) };
|
|
2136
2259
|
},
|
|
2137
2260
|
mail: (route, mail, headers) => sendMail(route, mail, headers),
|
|
@@ -2168,16 +2291,50 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
|
|
|
2168
2291
|
return value;
|
|
2169
2292
|
},
|
|
2170
2293
|
occurredAt: at,
|
|
2171
|
-
secret: async (label) => hmac(await worldSeed(m.service, root, at), label, 'base64url'),
|
|
2294
|
+
secret: async (label) => hmac(await worldSeed(m.service, root, at), scopeLabel(m, label), 'base64url'),
|
|
2295
|
+
account: async (field = credentialDoor?.account?.field ?? 'account') => {
|
|
2296
|
+
return worldAccount(m.service, root, credentialFields[field] ?? 'default', at, call.request.headers.get('x-volter-world-activity') === 'provisioning');
|
|
2297
|
+
},
|
|
2298
|
+
organization: async () => {
|
|
2299
|
+
const id = await context.account();
|
|
2300
|
+
const row = projectOwnerResources('_world', root, { type: '_world_organization', id }).find((row) => row.type === '_world_organization' && row.id === id && row.deleted !== true);
|
|
2301
|
+
return row ? { id, label: row.label as string, members: [...(row.members as string[])] } : { id, label: id, members: ['owner@world.test'] };
|
|
2302
|
+
},
|
|
2303
|
+
deployment: () => {
|
|
2304
|
+
const id = credentialFields[credentialDoor?.deployment?.field ?? 'deployment'] ?? '';
|
|
2305
|
+
if (id !== '' && !isWorldAccountName(id)) throw new Error('Credential deployment must be a name of at most 128 letters, digits, dots, underscores or hyphens');
|
|
2306
|
+
return id;
|
|
2307
|
+
},
|
|
2308
|
+
vendorName,
|
|
2309
|
+
tenant: async (resource, make) => {
|
|
2310
|
+
const id = await context.account();
|
|
2311
|
+
return retainNativeAccountLink(m.service, root, at, call, '_world_account', id, 'tenant', resource,
|
|
2312
|
+
async () => make(await context.organization()), async (link) => liveNativeRow(link));
|
|
2313
|
+
},
|
|
2314
|
+
deploymentObject: async (kind, resource, make) => {
|
|
2315
|
+
const organization = await context.account(), deployment = context.deployment();
|
|
2316
|
+
return retainNativeAccountLink(m.service, root, at, call, '_world_deployment_object', `${organization}/${deployment}/${kind}`, undefined, resource,
|
|
2317
|
+
() => make(deployment), async (link) => liveNativeRow(link));
|
|
2318
|
+
},
|
|
2319
|
+
accountKey: async (purpose, resource, key) => {
|
|
2320
|
+
const organization = await context.account(), deployment = context.deployment();
|
|
2321
|
+
return retainNativeAccountLink(m.service, root, at, call, '_world_account_key', `${organization}/${deployment}/${purpose}`, undefined, resource,
|
|
2322
|
+
() => key.make(), async (link) => {
|
|
2323
|
+
const row = liveNativeRow(link);
|
|
2324
|
+
if (!row) return undefined;
|
|
2325
|
+
const secret = await key.read(row);
|
|
2326
|
+
return secret === undefined ? undefined : { id: link.id, secret };
|
|
2327
|
+
});
|
|
2328
|
+
},
|
|
2172
2329
|
record: async (type, fields, id, operation) => {
|
|
2173
2330
|
if (!type.startsWith('_')) throw new Error(`semantics: ${type} is not a bookkeeping type (bookkeeping types start with _)`);
|
|
2174
2331
|
// a bookkeeping subject a resource counts (`counts` naming it: sentry's issue users) keeps that count as any
|
|
2175
2332
|
// child does, the parent it left recounted too
|
|
2176
|
-
const keeps = counted(m, type);
|
|
2333
|
+
const keeps = counted(m, type), physicalType = storedType(m, type);
|
|
2177
2334
|
if (id !== undefined) {
|
|
2178
2335
|
const before = operation ? stateBeforeWrite(m, type, id, root) : undefined;
|
|
2179
|
-
const prior = keeps ? resourcesOfType(m.service,
|
|
2180
|
-
const { resource: row, result } = await applyTwinWrite(m.service, { operation: operation ?? `${type.slice(1)}.record`, subjectType:
|
|
2336
|
+
const prior = keeps ? resourcesOfType(m.service, physicalType, root).find((r) => r.id === id) : undefined;
|
|
2337
|
+
const { resource: row, result } = await applyTwinWrite(m.service, { operation: operation ?? `${type.slice(1)}.record`, subjectType: physicalType, subjectId: id, fields, ...provisioningInput(call), occurredAt: at, actor: { kind: 'system' } }, root);
|
|
2181
2338
|
if (operation) observeStateWrite(m, result, before, root, stateChecks);
|
|
2182
2339
|
if (keeps) await keepCounts(m, call, type, row as unknown as Record<string, unknown>, params, root, at, prior && { ...prior } as Record<string, unknown>);
|
|
2183
2340
|
return id;
|
|
@@ -2188,25 +2345,25 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
|
|
|
2188
2345
|
const prefix = `${type.slice(1)}_`;
|
|
2189
2346
|
let max = 0n;
|
|
2190
2347
|
for (const r of resources) {
|
|
2191
|
-
if (r.type !==
|
|
2348
|
+
if (r.type !== physicalType) continue;
|
|
2192
2349
|
const digits = r.id.startsWith(prefix) ? r.id.slice(prefix.length) : '';
|
|
2193
2350
|
if (/^\d+$/.test(digits) && BigInt(digits) > max) max = BigInt(digits);
|
|
2194
2351
|
}
|
|
2195
2352
|
return `${prefix}${max + 1n}`;
|
|
2196
2353
|
})();
|
|
2197
|
-
return { kind: 'write', value: subject, write: { operation: operation ?? `${type.slice(1)}.record`, subjectType:
|
|
2198
|
-
}, root, () => resourcesOfType(m.service,
|
|
2354
|
+
return { kind: 'write', value: subject, write: { operation: operation ?? `${type.slice(1)}.record`, subjectType: physicalType, subjectId: subject, fields, ...provisioningInput(call), occurredAt: at, actor: { kind: 'system' } } };
|
|
2355
|
+
}, root, () => resourcesOfType(m.service, physicalType, root));
|
|
2199
2356
|
if (operation && result) observeStateWrite(m, result, undefined, root, stateChecks);
|
|
2200
|
-
const made = keeps && value !== undefined ? resourcesOfType(m.service,
|
|
2357
|
+
const made = keeps && value !== undefined ? resourcesOfType(m.service, physicalType, root).find((r) => r.id === value) : undefined;
|
|
2201
2358
|
if (made) await keepCounts(m, call, type, made as unknown as Record<string, unknown>, params, root, at);
|
|
2202
2359
|
return value;
|
|
2203
2360
|
},
|
|
2204
2361
|
get machines() {
|
|
2205
|
-
const enrolled = resourcesOfType(m.service, MACHINE_POOL, root).find((r) => r.id === 'pool') as unknown as
|
|
2206
|
-
return guardedMachinePool(poolFor(m.service, root, enrolled?.kind ?? 'none', enrolled?.httpReplies
|
|
2362
|
+
const enrolled = resourcesOfType(m.service, MACHINE_POOL, root).find((r) => r.id === 'pool') as unknown as { kind?: MachinePool['kind']; httpReplies?: MachineHttpReply[] } | undefined;
|
|
2363
|
+
return guardedMachinePool(poolFor(m.service, root, enrolled?.kind ?? 'none', enrolled?.httpReplies), m.service, ['retrieve', 'list'].includes(call.operation.class) || (m.reads?.includes(call.operation.id) ?? false));
|
|
2207
2364
|
},
|
|
2208
|
-
signingKey: (label, alg) => worldSigningKey(m.service, root, at, label, alg ?? 'RS256'),
|
|
2209
|
-
heldSigningKey: (label, alg) => heldWorldSigningKey(m.service, root, label, alg ?? 'RS256'),
|
|
2365
|
+
signingKey: (label, alg) => worldSigningKey(m.service, root, at, scopeLabel(m, label), alg ?? 'RS256'),
|
|
2366
|
+
heldSigningKey: (label, alg) => heldWorldSigningKey(m.service, root, scopeLabel(m, label), alg ?? 'RS256'),
|
|
2210
2367
|
create: (resource, fields, operation) => atomicWrite(m, call, resource, (snapshot) => {
|
|
2211
2368
|
const id = mintId(m, resource, root, at, snapshot);
|
|
2212
2369
|
return { id, fields: typeof fields === 'function' ? fields(id) : fields };
|
|
@@ -2217,9 +2374,13 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
|
|
|
2217
2374
|
if (!rules?.length) throw new Error('createUnique requires declared unique keys');
|
|
2218
2375
|
let existing: Record<string, unknown> | undefined;
|
|
2219
2376
|
const row = await atomicWrite(m, call, resource, (snapshot) => {
|
|
2220
|
-
const id = mintId(m, resource, root, at, snapshot);
|
|
2377
|
+
const id = options?.id ?? mintId(m, resource, root, at, snapshot);
|
|
2221
2378
|
const selected = typeof fields === 'function' ? fields(id) : fields;
|
|
2222
|
-
const hit = snapshot.find((r) =>
|
|
2379
|
+
const hit = snapshot.find((r) => {
|
|
2380
|
+
if (r.type !== storedType(m, resource) || r.deleted === true) return false;
|
|
2381
|
+
const fields = { [m.resources[resource]?.idAs ?? 'id']: r.id, ...ownFields(r) };
|
|
2382
|
+
return rules.some((rule) => rule.fields.every((field) => selected[field] !== undefined && fields[field] === selected[field]));
|
|
2383
|
+
});
|
|
2223
2384
|
if (hit) { existing = view(m, resource, hit); return undefined; }
|
|
2224
2385
|
return { id, fields: selected };
|
|
2225
2386
|
}, operation, params, root, at, undefined, stateChecks, options?.input === 'omit');
|
|
@@ -2251,11 +2412,20 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
|
|
|
2251
2412
|
},
|
|
2252
2413
|
writeDetailed: (resource, id, fields, operation) => writeDetailed(m, call, resource, id, fields, operation, params, root, at, stateChecks),
|
|
2253
2414
|
resolve: (resource, id) => resolveSubjectId(m.service, storedType(m, resource), id, root),
|
|
2254
|
-
storedType: (resource) =>
|
|
2415
|
+
storedType: (resource) => m.resources[resource]?.storedAs ?? resource,
|
|
2255
2416
|
ok: (fields) => Response.json({ ...(m.success ?? {}), ...fields }),
|
|
2256
2417
|
row: (resource, id, opts) => { const row = stored(m, resource, root, opts).find((r) => r.id === id); return row ? rawRow(resource, row) : undefined; },
|
|
2257
2418
|
rowsRaw: (resource, opts) => stored(m, resource, root, opts).map((row) => rawRow(resource, row)),
|
|
2258
|
-
tree: () =>
|
|
2419
|
+
tree: () => {
|
|
2420
|
+
const rows = twinResources(m.service, root);
|
|
2421
|
+
if (m.tenant?.storage !== 'namespaced') return rows as Array<Record<string, unknown>>;
|
|
2422
|
+
const account = accountScope(m);
|
|
2423
|
+
return rows.filter(row => {
|
|
2424
|
+
const [base, encoded] = row.type.split(TENANT_TYPE);
|
|
2425
|
+
const name = Object.keys(m.resources).find(name => (m.resources[name]?.storedAs ?? name) === base) ?? base!;
|
|
2426
|
+
return account === undefined || m.resources[name]?.shared || (encoded ? decodeURIComponent(encoded) : 'default') === account;
|
|
2427
|
+
}).map(row => rawRow(row.type.split(TENANT_TYPE)[0]!, row));
|
|
2428
|
+
},
|
|
2259
2429
|
history: (resource, id) => subjectHistory(m.service, { type: storedType(m, resource), id }, root).map((e) => ({ ...(e.operation ? { operation: e.operation } : {}), ...(e.fields ? { fields: e.fields as Record<string, unknown> } : {}), ...(e.occurredAt ? { occurredAt: e.occurredAt } : {}) })),
|
|
2260
2430
|
raw: (body, init = {}) => new Response(body, { status: init.status ?? 200, headers: init.headers ?? {} }),
|
|
2261
2431
|
atomically: async (decide) => {
|
|
@@ -2309,17 +2479,17 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
|
|
|
2309
2479
|
engine: scope.database === undefined ? unboundDatabase(m.service) : managedDatabase(scope.database, { readOnly: scope.readOnly === true || isReadOnlyRequest(call.request) }),
|
|
2310
2480
|
redis: (commands, options) => execRedisRun(commands, {
|
|
2311
2481
|
root, occurredAt: at, service: m.service, readOnly: scope.readOnly === true || isReadOnlyRequest(call.request), dialect: options.dialect,
|
|
2312
|
-
...(options.database === undefined ? {} : { database: options.database }),
|
|
2482
|
+
...(accountScope(m) && accountScope(m) !== 'default' ? { database: scopeLabel(m, options.database ?? 'default') } : options.database === undefined ? {} : { database: options.database }),
|
|
2313
2483
|
...(options.transaction === undefined ? {} : { transaction: options.transaction }),
|
|
2314
2484
|
}),
|
|
2315
|
-
live: liveAccess({ rows: (type) =>
|
|
2485
|
+
live: liveAccess({ rows: (type) => stored(m, type, root).map(row => rawRow(type, row)), blob: (key) => readResourceBlob(m.service, scopeLabel(m, key), root) }, call.request),
|
|
2316
2486
|
blobs: {
|
|
2317
|
-
put: (key, bytes) => writeResourceBlob(m.service, key, bytes, root),
|
|
2318
|
-
putStream: (key, body, accept) => writeResourceBlobStream(m.service, key, body, accept, root),
|
|
2319
|
-
get: (key) => readResourceBlob(m.service, key, root),
|
|
2320
|
-
remove: (key) => removeResourceBlob(m.service, key, root),
|
|
2321
|
-
size: (key) => resourceBlobSize(m.service, key, root),
|
|
2322
|
-
range: (key, start, endInclusive) => readResourceBlobRange(m.service, key, start, endInclusive, root),
|
|
2487
|
+
put: (key, bytes) => writeResourceBlob(m.service, scopeLabel(m, key), bytes, root),
|
|
2488
|
+
putStream: (key, body, accept) => writeResourceBlobStream(m.service, scopeLabel(m, key), body, accept, root),
|
|
2489
|
+
get: (key) => readResourceBlob(m.service, scopeLabel(m, key), root),
|
|
2490
|
+
remove: (key) => removeResourceBlob(m.service, scopeLabel(m, key), root),
|
|
2491
|
+
size: (key) => resourceBlobSize(m.service, scopeLabel(m, key), root),
|
|
2492
|
+
range: (key, start, endInclusive) => readResourceBlobRange(m.service, scopeLabel(m, key), start, endInclusive, root),
|
|
2323
2493
|
},
|
|
2324
2494
|
merge: (resource, prior, next) => mergeField(m.resources[resource]?.update, prior, next),
|
|
2325
2495
|
conflict: (resource, fields, except) => {
|
|
@@ -2365,12 +2535,14 @@ const BATCH_REFUSED_READS = ['get', 'rows', 'tree', 'history', 'find', 'list', '
|
|
|
2365
2535
|
/** The members that write, or wait on writes, or answer as another context: each lands the staged writes first. */
|
|
2366
2536
|
const BATCH_LANDING_MEMBERS = ['create', 'createUnique', 'change', 'accumulate', 'writeDetailed', 'record', 'atomically', 'remove', 'issue', 'at', 'over', 'core', 'awaitWrite', 'awaitCondition',
|
|
2367
2537
|
// a World's seed and signing keys are written when first asked (worldSeed, worldSigningKey, as a vendor's move)
|
|
2368
|
-
'secret', 'signingKey'] as const;
|
|
2538
|
+
'secret', 'signingKey', 'account', 'organization', 'tenant', 'deploymentObject', 'accountKey'] as const;
|
|
2369
2539
|
|
|
2370
2540
|
/** `ctx.batch` over a context whose writes may be grouped (see `batchable`). */
|
|
2371
2541
|
function bindBatch(m: DerivedManifest, call: DerivedCall, context: SemanticsContext, params: Record<string, unknown>, root: string | undefined, at: string,
|
|
2372
2542
|
rawRow: (resource: string, row: TwinResource) => Record<string, unknown>): void {
|
|
2373
2543
|
let staged: StagedWrite[] | undefined;
|
|
2544
|
+
// A callback's native object must land before the helper writes its retained link.
|
|
2545
|
+
let nativeAccountWrites = 0;
|
|
2374
2546
|
// each staged subject as it will stand, by `type:id`, in the order first written
|
|
2375
2547
|
const subjects = new Map<string, Subject>();
|
|
2376
2548
|
const land = async (): Promise<void> => {
|
|
@@ -2384,7 +2556,13 @@ function bindBatch(m: DerivedManifest, call: DerivedCall, context: SemanticsCont
|
|
|
2384
2556
|
for (const name of BATCH_LANDING_MEMBERS) {
|
|
2385
2557
|
const plain = ctx[name];
|
|
2386
2558
|
if (typeof plain !== 'function') continue;
|
|
2387
|
-
ctx[name] = async (...args: unknown[]) => {
|
|
2559
|
+
ctx[name] = async (...args: unknown[]) => {
|
|
2560
|
+
await land();
|
|
2561
|
+
const native = name === 'tenant' || name === 'deploymentObject' || name === 'accountKey';
|
|
2562
|
+
if (native) nativeAccountWrites++;
|
|
2563
|
+
try { return await (plain as (...a: unknown[]) => unknown)(...args); }
|
|
2564
|
+
finally { if (native) nativeAccountWrites--; }
|
|
2565
|
+
};
|
|
2388
2566
|
}
|
|
2389
2567
|
for (const name of BATCH_REFUSED_READS) {
|
|
2390
2568
|
const plain = ctx[name];
|
|
@@ -2397,7 +2575,7 @@ function bindBatch(m: DerivedManifest, call: DerivedCall, context: SemanticsCont
|
|
|
2397
2575
|
const plainWrite = context.write, plainRow = context.row, plainRowsRaw = context.rowsRaw;
|
|
2398
2576
|
context.write = async (resource, id, fields, operation) => {
|
|
2399
2577
|
// a vendor's own move (ctx.asVendor) is the clock's write, recorded as the system's: it lands alone, in its place
|
|
2400
|
-
if (!staged || inVendorMove()) { await land(); return plainWrite(resource, id, fields, operation); }
|
|
2578
|
+
if (!staged || nativeAccountWrites > 0 || inVendorMove()) { await land(); return plainWrite(resource, id, fields, operation); }
|
|
2401
2579
|
const type = storedType(m, resource), key = `${type}:${id}`;
|
|
2402
2580
|
// the one subject, as the memoized tree holds it (readTreeMap would copy the whole tree on every staged write)
|
|
2403
2581
|
const subject = foldLocalWrite(subjects.get(key) ?? heldSubject(m.service, type, id, root), type, id, fields as SubjectFields, at);
|
|
@@ -2614,7 +2792,7 @@ export function authRefusal(m: DerivedManifest, request: Request, root?: string)
|
|
|
2614
2792
|
const held = m.auth.held;
|
|
2615
2793
|
if (held && !held.standing?.includes(key)) {
|
|
2616
2794
|
const hash = sha256(key);
|
|
2617
|
-
const holds =
|
|
2795
|
+
const holds = stored(m, held.storedAs, root).some((r) => r.deleted !== true && (r as Record<string, unknown>)[held.hashField] === hash && (held.unless === undefined || (r as Record<string, unknown>)[held.unless] !== true));
|
|
2618
2796
|
if (!holds) return vendorError(errorManifest, invalid);
|
|
2619
2797
|
}
|
|
2620
2798
|
return undefined;
|
|
@@ -2691,7 +2869,7 @@ export function crossCutting(m: DerivedManifest, opts: CoreScope & { readOnly?:
|
|
|
2691
2869
|
const url = new URL(request.url);
|
|
2692
2870
|
const signature = hashFieldValue({ path: url.pathname + url.search, body: await bodyTextOf(request.clone()) });
|
|
2693
2871
|
const id = `idem_${hex(key)}`;
|
|
2694
|
-
const prior = twinResources(m.service, opts.root).find((r) => r.type === m.idempotency!.storedAs && r.id === id) as Record<string, unknown> | undefined;
|
|
2872
|
+
const prior = twinResources(m.service, opts.root).find((r) => r.type === storedType(m, m.idempotency!.storedAs) && r.id === id) as Record<string, unknown> | undefined;
|
|
2695
2873
|
if (prior) {
|
|
2696
2874
|
if (m.idempotency.conflict && prior.requestHash !== undefined && prior.requestHash !== signature) return vendorError(m, m.idempotency.conflict);
|
|
2697
2875
|
const answer = prior.response as { status: number; body?: unknown; text?: string; contentType?: string };
|
|
@@ -2704,7 +2882,7 @@ export function crossCutting(m: DerivedManifest, opts: CoreScope & { readOnly?:
|
|
|
2704
2882
|
if (m.idempotency.onlySuccess && (response.status < 200 || response.status >= 300)) return response;
|
|
2705
2883
|
const contentType = response.headers.get('content-type') ?? 'application/json';
|
|
2706
2884
|
const text = await response.clone().text();
|
|
2707
|
-
await applyTwinWrite(m.service, { operation: 'idempotency.record', subjectType: m.idempotency.storedAs, subjectId: id, fields: { response: { status: response.status, text, contentType }, requestHash: signature }, occurredAt: (opts.clock ?? worldNow)(), actor: { kind: 'system' } }, opts.root);
|
|
2885
|
+
await applyTwinWrite(m.service, { operation: 'idempotency.record', subjectType: storedType(m, m.idempotency.storedAs), subjectId: id, fields: { response: { status: response.status, text, contentType }, requestHash: signature }, occurredAt: (opts.clock ?? worldNow)(), actor: { kind: 'system' } }, opts.root);
|
|
2708
2886
|
return response;
|
|
2709
2887
|
}
|
|
2710
2888
|
}
|