Compare commits

...

1 Commits

Author SHA1 Message Date
Guido D'Orsi
edd758c216 feat: implement 2025-04-08 17:57:37 +02:00
5 changed files with 526 additions and 29 deletions

View File

@@ -26,6 +26,18 @@ function hasReadAccess(value: CoValue, key: string | number) {
);
}
function setChildrenState(
value: CoValue,
key: string | number,
state: "unauthorized" | "unfulfilled" | "fulfilled",
) {
(
value as unknown as {
_refs: { [key: string]: Ref<CoValue> | undefined };
}
)._refs?.[key]?.setChildrenState(state);
}
function isOptionalField(value: CoValue, key: string): boolean {
return (
((value as CoMap)._schema[key] as RefEncoded<CoValue>)?.optional ?? false
@@ -53,6 +65,10 @@ export function fulfillsDepth(depth: any, value: CoValue): FulfillsDepthResult {
for (const [key, item] of Object.entries(value)) {
if (map._raw.get(key) !== undefined) {
if (!item) {
if (depth.$skipInvalid === true) {
continue;
}
if (hasReadAccess(map, key)) {
result = "unfulfilled";
continue;
@@ -62,16 +78,22 @@ export function fulfillsDepth(depth: any, value: CoValue): FulfillsDepthResult {
}
const innerResult = fulfillsDepth(depth.$each, item);
setChildrenState(value, key, innerResult);
if (depth.$skipInvalid === true) {
continue;
}
if (innerResult === "unfulfilled") {
result = "unfulfilled";
} else if (
innerResult === "unauthorized" &&
!isOptionalField(value, ItemsSym)
) {
} else if (innerResult === "unauthorized") {
return "unauthorized"; // If any item is unauthorized, the whole thing is unauthorized
}
} else if (!isOptionalField(value, ItemsSym)) {
if (depth.$skipInvalid === true) {
continue;
}
return "unfulfilled";
}
}
@@ -121,13 +143,15 @@ export function fulfillsDepth(depth: any, value: CoValue): FulfillsDepthResult {
}
const innerResult = fulfillsDepth(depth[key], item);
setChildrenState(value, key, innerResult);
if (depth.$skipInvalid === true) {
continue;
}
if (innerResult === "unfulfilled") {
result = "unfulfilled";
} else if (
innerResult === "unauthorized" &&
!isOptionalField(value, key)
) {
} else if (innerResult === "unauthorized") {
return "unauthorized"; // If any item is unauthorized, the whole thing is unauthorized
}
}
@@ -142,6 +166,10 @@ export function fulfillsDepth(depth: any, value: CoValue): FulfillsDepthResult {
for (const [key, item] of (value as CoList).entries()) {
if (hasRefValue(value, key)) {
if (!item) {
if (depth.$skipInvalid === true) {
continue;
}
if (hasReadAccess(value, key)) {
result = "unfulfilled";
continue;
@@ -151,16 +179,22 @@ export function fulfillsDepth(depth: any, value: CoValue): FulfillsDepthResult {
}
const innerResult = fulfillsDepth(depth.$each, item);
setChildrenState(value, key, innerResult);
if (depth.$skipInvalid === true) {
continue;
}
if (innerResult === "unfulfilled") {
result = "unfulfilled";
} else if (
innerResult === "unauthorized" &&
!isOptionalField(value, ItemsSym)
) {
} else if (innerResult === "unauthorized") {
return "unauthorized"; // If any item is unauthorized, the whole thing is unauthorized
}
} else if (!isOptionalField(value, ItemsSym)) {
if (depth.$skipInvalid === true) {
continue;
}
return "unfulfilled";
}
}
@@ -235,6 +269,7 @@ export type RefsToResolve<
DepthLimit,
[0, ...CurrentDepth]
>;
$skipInvalid?: true;
}
| boolean
: // Basically V extends CoMap | Group | Account - but if we used that we'd introduce circularity into the definition of CoMap itself
@@ -256,6 +291,7 @@ export type RefsToResolve<
DepthLimit,
[0, ...CurrentDepth]
>;
$skipInvalid?: true;
}
: never)
| boolean
@@ -270,6 +306,7 @@ export type RefsToResolve<
DepthLimit,
[0, ...CurrentDepth]
>;
$skipInvalid?: true;
}
| boolean
: boolean);
@@ -285,6 +322,8 @@ export type Resolved<T, R extends RefsToResolve<T> | undefined> = DeeplyLoaded<
[]
>;
type isNullable<Depth> = Depth extends { $skipInvalid: boolean } ? null : never;
export type DeeplyLoaded<
V,
Depth,
@@ -299,13 +338,16 @@ export type DeeplyLoaded<
? UnCoNotNull<Item> extends CoValue
? Depth extends { $each: infer ItemDepth }
? // Deeply loaded CoList
(UnCoNotNull<Item> &
DeeplyLoaded<
UnCoNotNull<Item>,
ItemDepth,
DepthLimit,
[0, ...CurrentDepth]
>)[] &
(
| (Clean<Item> &
DeeplyLoaded<
Clean<Item>,
ItemDepth,
DepthLimit,
[0, ...CurrentDepth]
>)
| isNullable<Depth>
)[] &
V // the CoList base type needs to be intersected after so that built-in methods return the correct narrowed array type
: never
: V
@@ -315,12 +357,14 @@ export type DeeplyLoaded<
? Depth extends { $each: infer ItemDepth }
? // Deeply loaded Record-like CoMap
{
[key: string]: DeeplyLoaded<
Clean<V[ItemsSym]>,
ItemDepth,
DepthLimit,
[0, ...CurrentDepth]
>;
[key: string]:
| DeeplyLoaded<
Clean<V[ItemsSym]>,
ItemDepth,
DepthLimit,
[0, ...CurrentDepth]
>
| isNullable<Depth>;
} & V // same reason as in CoList
: never
: keyof Depth extends never // Depth = {}

View File

@@ -17,6 +17,8 @@ import { coValuesCache } from "../lib/cache.js";
const TRACE_ACCESSES = false;
export class Ref<out V extends CoValue> {
childrenErrorState: "unauthorized" | "unfulfilled" | undefined;
constructor(
readonly id: ID<V>,
readonly controlledAccount: Account | AnonymousJazzAgent,
@@ -59,6 +61,14 @@ export class Ref<out V extends CoValue> {
return true;
}
setChildrenState(state: "unauthorized" | "unfulfilled" | "fulfilled") {
if (state === "fulfilled") {
this.childrenErrorState = undefined;
} else {
this.childrenErrorState = state;
}
}
getValueWithoutAccessCheck() {
const node = this.getNode();
const raw = node.getLoaded(this.id as unknown as CoID<RawCoValue>);
@@ -77,6 +87,10 @@ export class Ref<out V extends CoValue> {
return null;
}
if (this.childrenErrorState) {
return null;
}
return this.getValueWithoutAccessCheck();
}

View File

@@ -0,0 +1,319 @@
import { cojsonInternals } from "cojson";
import { WasmCrypto } from "cojson/crypto/WasmCrypto";
import { assert, describe, expect, expectTypeOf, test, vi } from "vitest";
import {
Account,
CoFeed,
CoList,
CoMap,
Group,
ID,
Profile,
SessionID,
co,
createJazzContextFromExistingCredentials,
isControlledAccount,
} from "../index.js";
import { randomSessionProvider } from "../internal.js";
import { createJazzTestAccount, linkAccounts } from "../testing.js";
import { waitFor } from "./utils.js";
const Crypto = await WasmCrypto.create();
const { connectedPeers } = cojsonInternals;
class TestMap extends CoMap {
list = co.ref(TestList);
optionalRef = co.ref(InnermostMap, { optional: true });
}
class TestList extends CoList.Of(co.ref(() => InnerMap)) {}
class InnerMap extends CoMap {
stream = co.ref(TestStream);
}
class TestStream extends CoFeed.Of(co.ref(() => InnermostMap)) {}
class InnermostMap extends CoMap {
value = co.string;
}
const me = await createJazzTestAccount({});
const map = TestMap.create({
list: TestList.create([], { owner: me }),
});
describe("Deep loading with depth arg", async () => {
test("load without resolve", async () => {
const map1 = await TestMap.load(map.id);
function validateType(map: TestMap | null) {
map;
}
validateType(map1);
});
test("load with resolve { list: true }", async () => {
const map2 = await TestMap.load(map.id, {
resolve: { list: true },
});
expectTypeOf(map2).toEqualTypeOf<
| (TestMap & {
list: TestList;
})
| null
>();
});
test("load with resolve { list: { $each: true } }", async () => {
const map3 = await TestMap.load(map.id, {
resolve: { list: { $each: true } },
});
expectTypeOf(map3).toEqualTypeOf<
| (TestMap & {
list: TestList & InnerMap[];
})
| null
>();
});
test("load with resolve { optionalRef: true }", async () => {
const map3a = await TestMap.load(map.id, {
resolve: { optionalRef: true } as const,
});
expectTypeOf(map3a).toEqualTypeOf<
| (TestMap & {
optionalRef: InnermostMap | undefined;
})
| null
>();
});
test("load with resolve { list: { $each: { stream: true } } }", async () => {
const map4 = await TestMap.load(map.id, {
resolve: { list: { $each: { stream: true } } },
});
expectTypeOf(map4).toEqualTypeOf<
| (TestMap & {
list: TestList & (InnerMap & { stream: TestStream })[];
})
| null
>();
});
test("load with resolve { list: { $each: { stream: { $each: true } } } }", async () => {
const map5 = await TestMap.load(map.id, {
resolve: { list: { $each: { stream: { $each: true } } } },
});
type ExpectedMap5 =
| (TestMap & {
list: TestList &
(InnerMap & {
stream: TestStream & {
byMe?: { value: InnermostMap };
inCurrentSession?: { value: InnermostMap };
perSession: {
[sessionID: SessionID]: {
value: InnermostMap;
};
};
} & {
[key: ID<Account>]: { value: InnermostMap };
};
})[];
})
| null;
expectTypeOf(map5).toEqualTypeOf<ExpectedMap5>();
});
test("should handle $skipInvalid on CoList", async () => {
class List extends CoList.Of(co.ref(() => InnerMap)) {}
const result = await List.load("x" as ID<List>, {
resolve: { $each: true, $skipInvalid: true },
});
function validateType(map: ((InnerMap | null)[] & List) | null) {
map;
}
validateType(result);
expectTypeOf(result).toEqualTypeOf<((InnerMap | null)[] & List) | null>();
});
test("should handle $skipInvalid on CoMap.Record", async () => {
class Record extends CoMap.Record(co.ref(() => InnerMap)) {}
const result = await Record.load("x" as ID<Record>, {
resolve: { $each: true, $skipInvalid: true },
});
type ExpectedResult =
| ({
[key: string]: InnerMap | null;
} & Record)
| null;
function validateType(map: ExpectedResult) {
map;
}
validateType(result);
expectTypeOf(result).toEqualTypeOf<ExpectedResult>();
});
});
class CustomProfile extends Profile {
stream = co.ref(TestStream);
}
class CustomAccount extends Account {
profile = co.ref(CustomProfile);
root = co.ref(TestMap);
async migrate(
this: CustomAccount,
creationProps?: { name: string } | undefined,
) {
if (creationProps) {
const profileGroup = Group.create(this);
this.profile = CustomProfile.create(
{
name: creationProps.name,
stream: TestStream.create([], this),
},
profileGroup,
);
this.root = TestMap.create({ list: TestList.create([], this) }, this);
}
const thisLoaded = await this.ensureLoaded({
resolve: {
profile: { stream: true },
root: { list: true },
},
});
expectTypeOf(thisLoaded).toEqualTypeOf<
CustomAccount & {
profile: CustomProfile & {
stream: TestStream;
};
root: TestMap & {
list: TestList;
};
}
>();
}
}
test("Deep loading within account", async () => {
const me = await CustomAccount.create({
creationProps: { name: "Hermes Puggington" },
crypto: Crypto,
});
const meLoaded = await me.ensureLoaded({
resolve: {
profile: { stream: true },
root: { list: true },
},
});
expectTypeOf(meLoaded).toEqualTypeOf<
CustomAccount & {
profile: CustomProfile & {
stream: TestStream;
};
root: TestMap & {
list: TestList;
};
}
>();
});
class RecordLike extends CoMap.Record(co.ref(TestMap)) {}
test("Deep loading a record-like coMap", async () => {
const me = await Account.create({
creationProps: { name: "Hermes Puggington" },
crypto: Crypto,
});
const [initialAsPeer, secondPeer] = connectedPeers("initial", "second", {
peer1role: "server",
peer2role: "client",
});
if (!isControlledAccount(me)) {
throw "me is not a controlled account";
}
me._raw.core.node.syncManager.addPeer(secondPeer);
const { account: meOnSecondPeer } =
await createJazzContextFromExistingCredentials({
credentials: {
accountID: me.id,
secret: me._raw.agentSecret,
},
sessionProvider: randomSessionProvider,
peersToLoadFrom: [initialAsPeer],
crypto: Crypto,
});
const record = RecordLike.create(
{
key1: TestMap.create(
{ list: TestList.create([], { owner: me }) },
{ owner: me },
),
key2: TestMap.create(
{ list: TestList.create([], { owner: me }) },
{ owner: me },
),
},
{ owner: me },
);
const recordLoaded = await RecordLike.load(record.id, {
loadAs: meOnSecondPeer,
resolve: {
$each: { list: { $each: true } },
},
});
expectTypeOf(recordLoaded).toEqualTypeOf<
| (RecordLike & {
[key: string]: TestMap & {
list: TestList & InnerMap[];
};
})
| null
>();
});
test("The resolve type doesn't accept extra keys", async () => {
const me = await CustomAccount.create({
creationProps: { name: "Hermes Puggington" },
crypto: Crypto,
});
const meLoaded = await me.ensureLoaded({
resolve: {
// @ts-expect-error
profile: { stream: true, extraKey: true },
// @ts-expect-error
root: { list: true, extraKey: true },
},
});
expectTypeOf(meLoaded).toEqualTypeOf<
CustomAccount & {
profile: CustomProfile & {
stream: TestStream;
extraKey: never;
};
root: TestMap & {
list: TestList;
extraKey: never;
};
}
>();
});

View File

@@ -439,6 +439,83 @@ describe("Deep loading with unauthorized account", async () => {
expect(mapOnAlice).toBe(null);
});
test("unaccessible list element with $skipInvalid", async () => {
class Person extends CoMap {
name = co.string;
}
class Friends extends CoList.Of(co.ref(Person)) {}
const list = Friends.create(
[
Person.create({ name: "Jane" }, onlyBob),
Person.create({ name: "Alice" }, group),
],
group,
);
const listOnAlice = await Friends.load(list.id, {
resolve: { $each: true, $skipInvalid: true },
loadAs: alice,
});
assert(listOnAlice, "listOnAlice is null");
expect(listOnAlice[0]).toBeNull();
expect(listOnAlice[1]).not.toBeNull();
expect(listOnAlice[1]?.name).toBe("Alice");
expect(listOnAlice).toHaveLength(2);
});
test("unaccessible list element with $skipInvalid and $each with depth", async () => {
class Person extends CoMap {
name = co.string;
friends = co.optional.ref(Friends);
}
class Friends extends CoList.Of(co.ref(Person)) {}
const list = Friends.create(
[
Person.create(
{
name: "Jane",
friends: Friends.create(
[Person.create({ name: "Bob" }, onlyBob)],
group,
),
},
group,
),
Person.create(
{
name: "Alice",
friends: Friends.create(
[Person.create({ name: "Bob" }, group)],
group,
),
},
group,
),
],
group,
);
// The error List -> Jane -> Bob should be propagated to the list element Jane
// and we should have [null, Alice]
const listOnAlice = await Friends.load(list.id, {
resolve: { $each: { friends: { $each: true } }, $skipInvalid: true },
loadAs: alice,
});
assert(listOnAlice, "listOnAlice is null");
expect(listOnAlice[0]).toBeNull();
expect(listOnAlice[1]).not.toBeNull();
expect(listOnAlice[1]?.name).toBe("Alice");
expect(listOnAlice[1]?.friends).not.toBeNull();
expect(listOnAlice[1]?.friends?.[0]?.name).toBe("Bob");
expect(listOnAlice).toHaveLength(2);
});
test("unaccessible optional element", async () => {
const map = TestMap.create(
{
@@ -538,6 +615,53 @@ describe("Deep loading with unauthorized account", async () => {
expect(mapOnAlice).toBe(null);
});
test("unaccessible record element", async () => {
class Person extends CoMap {
name = co.string;
}
class Friend extends CoMap.Record(co.ref(Person)) {}
const map = Friend.create(
{
jane: Person.create({ name: "Jane" }, onlyBob),
alice: Person.create({ name: "Alice" }, group),
},
group,
);
const friendsOnAlice = await Friend.load(map.id, {
resolve: { $each: true },
loadAs: alice,
});
expect(friendsOnAlice).toBeNull();
});
test("unaccessible record element with $skipInvalid", async () => {
class Person extends CoMap {
name = co.string;
}
class Friend extends CoMap.Record(co.ref(Person)) {}
const map = Friend.create(
{
jane: Person.create({ name: "Jane" }, onlyBob),
alice: Person.create({ name: "Alice" }, group),
},
group,
);
const friendsOnAlice = await Friend.load(map.id, {
resolve: { $each: true, $skipInvalid: true },
loadAs: alice,
});
assert(friendsOnAlice, "friendsOnAlice is null");
expect(friendsOnAlice.jane).toBeNull();
expect(friendsOnAlice.alice).not.toBeNull();
});
});
test("doesn't break on Map.Record key deletion when the key is referenced in the depth", async () => {

View File

@@ -4,10 +4,6 @@ import { defineConfig } from "vitest/config";
export default defineConfig({
root: "./",
test: {
typecheck: {
enabled: true,
checker: "tsc",
},
workspace: [
"packages/*",
"tests/browser-integration",