@k2b/cloud 0.23.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/package.json +2 -2
  2. package/src/_internal/capabilities.ts +230 -75
  3. package/src/_internal/define-app.ts +12 -3
  4. package/src/_internal/fixtures/filesv2-manifest-cloud-v0.29.0.json +887 -0
  5. package/src/_internal/page-responses.ts +1 -1
  6. package/src/_internal/registry.ts +78 -3
  7. package/src/ai/chat/blocks.tsx +2 -1
  8. package/src/ai/chat/messages.ts +6 -0
  9. package/src/ai/client/controller.ts +65 -20
  10. package/src/ai/client/transport.ts +5 -5
  11. package/src/ai/code-mode-skill.ts +2 -2
  12. package/src/ai/index.ts +0 -7
  13. package/src/ai/live-events.ts +1 -262
  14. package/src/ai/live.ts +31 -2
  15. package/src/ai/migrate.ts +35 -16
  16. package/src/ai/runtime.ts +1 -6
  17. package/src/ai/solid.ts +0 -5
  18. package/src/api/help.ts +2 -2
  19. package/src/api/pwa-phone.ts +10 -3
  20. package/src/api/search/schemas.ts +34 -0
  21. package/src/api/search.ts +118 -50
  22. package/src/browser/CloudResourceSearch.browser-harness.tsx +11 -0
  23. package/src/browser/CloudResourceSearch.tsx +208 -80
  24. package/src/browser/resource-search-messages.ts +15 -2
  25. package/src/browser/search-stream.ts +151 -0
  26. package/src/contracts/capabilities.ts +89 -24
  27. package/src/contracts/capability-compatibility.ts +28 -14
  28. package/src/contracts/file-provider.ts +158 -0
  29. package/src/contracts/index.ts +1 -0
  30. package/src/events/live-engine.ts +11 -7
  31. package/src/events/live.ts +32 -32
  32. package/src/server/help.ts +3 -3
  33. package/src/services/index.ts +1 -1
  34. package/src/services/outbox.ts +29 -115
  35. package/src/services/pdf/markdown.ts +22 -2
  36. package/src/services/pwa-devices.ts +74 -8
  37. package/src/shared/markdown/formula.ts +122 -26
  38. package/src/shared/markdown/index.ts +27 -14
  39. package/src/ssr/PwaLayout.tsx +2 -1
  40. package/src/styles/resource-search.css +19 -0
  41. package/src/ai/client/live-connection.ts +0 -141
  42. package/src/ai/live-messages.ts +0 -45
  43. package/src/ai/live-outbox.ts +0 -102
  44. package/src/ai/live-routes.ts +0 -412
  45. package/src/shared/markdown/extensions/info-blocks.ts +0 -79
@@ -436,6 +436,9 @@ export type CapabilityCommandManifest = z.infer<typeof CapabilityCommandManifest
436
436
 
437
437
  type CapabilityDefinitionCatalog<T> = Readonly<Record<string, T>>;
438
438
 
439
+ /** Offers files to other apps: local IDs of the list Query, the read Query, and the optional save Action. */
440
+ export type CapabilityFileProviderDeclaration = { list: string; read: string; save?: string };
441
+
439
442
  export type CapabilityDefinitions = {
440
443
  protocolVersion: typeof CAPABILITY_PROTOCOL_VERSION;
441
444
  presentation?: CapabilityPresentationCatalog;
@@ -443,6 +446,7 @@ export type CapabilityDefinitions = {
443
446
  queries?: CapabilityDefinitionCatalog<CapabilityQueryDefinition>;
444
447
  actions?: CapabilityDefinitionCatalog<CapabilityActionDefinition>;
445
448
  commands?: CapabilityDefinitionCatalog<CapabilityCommandDefinition>;
449
+ fileProvider?: CapabilityFileProviderDeclaration;
446
450
  };
447
451
 
448
452
  /**
@@ -504,17 +508,81 @@ export const CapabilityAppIdSchema = z
504
508
  .max(80)
505
509
  .regex(/^[a-z][a-z0-9-]*$/);
506
510
 
507
- export const CapabilityManifestSchema = z
508
- .object({
511
+ const MAX_MANIFEST_ENTRIES = 200;
512
+
513
+ export const CapabilityFileProviderManifestSchema = z
514
+ .object({ list: CapabilityLocalIdSchema, read: CapabilityLocalIdSchema, save: CapabilityLocalIdSchema.optional() })
515
+ .strict();
516
+
517
+ /** Keeps the sent entries this release reads completely. */
518
+ const readEntries = <T extends z.ZodType>(values: unknown, entry: T): z.output<T>[] | null => {
519
+ // Not a bounded list: leave the value to the manifest schema, which rejects it.
520
+ if (!Array.isArray(values) || values.length > MAX_MANIFEST_ENTRIES) return null;
521
+ return values.flatMap((value) => {
522
+ const parsed = entry.safeParse(value);
523
+ return parsed.success ? [parsed.data] : [];
524
+ });
525
+ };
526
+
527
+ /**
528
+ * Reads what this release understands from a manifest that a newer release may have produced.
529
+ *
530
+ * - Unknown top-level fields are ignored. A newer release may therefore only add something at the top
531
+ * level; anything that restricts an existing entry belongs inside that entry.
532
+ * - An entry with a field or value from a newer release is left out rather than stripped: its unknown
533
+ * part may change how the entry runs or who may use it, so guessing could widen access.
534
+ * - Entries that depend on an entry that is not there follow it: a Query scoped to a missing type is
535
+ * left out, and a type whose reader is missing keeps no reader.
536
+ * - A file provider with a field from a newer release is left out. Whether its operations are still
537
+ * there and match the contract is `fileProviderIssues`' job, which Core and consumers run.
538
+ *
539
+ * Every other entry stays available.
540
+ */
541
+ const readableManifest = (value: unknown): unknown => {
542
+ if (!value || typeof value !== "object" || Array.isArray(value)) return value;
543
+ const sent = value as Record<string, unknown>;
544
+ const types = readEntries(sent.types, CapabilityResourceTypeManifestSchema);
545
+ const queries = readEntries(sent.queries, CapabilityQueryManifestSchema);
546
+ const actions = readEntries(sent.actions, CapabilityActionManifestSchema);
547
+ const commands = readEntries(sent.commands, CapabilityCommandManifestSchema);
548
+ if (!types || !queries || !actions || !commands) return value;
549
+ const typeIds = new Set(types.map((type) => type.localId));
550
+ const readableQueries = queries.filter((query) => query.universalSearch?.scopeTypes?.every((type) => typeIds.has(type)) ?? true);
551
+ const queryIds = new Set(readableQueries.map((query) => query.localId));
552
+ const { fileProvider: sentProvider, ...rest } = sent;
553
+ const provider = CapabilityFileProviderManifestSchema.safeParse(sentProvider);
554
+ return {
555
+ ...rest,
556
+ ...(provider.success ? { fileProvider: provider.data } : {}),
557
+ types: types.map((type) => {
558
+ if (!type.reader || queryIds.has(type.reader)) return type;
559
+ const { reader: _missing, ...withoutReader } = type;
560
+ return withoutReader;
561
+ }),
562
+ queries: readableQueries,
563
+ actions,
564
+ commands,
565
+ };
566
+ };
567
+
568
+ /**
569
+ * Reads a manifest from any Cloud release with the same protocol version (see `readableManifest`).
570
+ * Producers stay strict: `app.start()` registers exactly the manifest its release defines.
571
+ */
572
+ export const CapabilityManifestSchema = z.preprocess(
573
+ readableManifest,
574
+ z.object({
509
575
  protocolVersion: z.literal(CAPABILITY_PROTOCOL_VERSION),
510
576
  appId: CapabilityAppIdSchema,
511
577
  manifestHash: z.string().regex(/^[a-f0-9]{64}$/),
512
- types: z.array(CapabilityResourceTypeManifestSchema).max(200),
513
- queries: z.array(CapabilityQueryManifestSchema).max(200),
514
- actions: z.array(CapabilityActionManifestSchema).max(200),
515
- commands: z.array(CapabilityCommandManifestSchema).max(200),
516
- })
517
- .strict();
578
+ types: z.array(CapabilityResourceTypeManifestSchema).max(MAX_MANIFEST_ENTRIES),
579
+ queries: z.array(CapabilityQueryManifestSchema).max(MAX_MANIFEST_ENTRIES),
580
+ actions: z.array(CapabilityActionManifestSchema).max(MAX_MANIFEST_ENTRIES),
581
+ commands: z.array(CapabilityCommandManifestSchema).max(MAX_MANIFEST_ENTRIES),
582
+ // Optional without a default: a manifest without a provider keeps the shape and hash of earlier releases.
583
+ fileProvider: CapabilityFileProviderManifestSchema.optional(),
584
+ }),
585
+ );
518
586
 
519
587
  export type CapabilityResourceTypeManifest = z.infer<typeof CapabilityResourceTypeManifestSchema>;
520
588
  export type CapabilityQueryManifest = z.infer<typeof CapabilityQueryManifestSchema>;
@@ -533,22 +601,19 @@ export const resolveCapabilityResourceReader = (manifest: CapabilityManifest, re
533
601
  return manifest.queries.find((candidate) => candidate.localId === type.reader) ?? null;
534
602
  };
535
603
 
536
- export const CapabilityCatalogAppSchema = z
537
- .object({
538
- appId: CapabilityAppIdSchema,
539
- appName: z.string().min(1).max(200),
540
- appIcon: z.string().min(1).max(120),
541
- appDescription: z.string().max(1000),
542
- manifest: CapabilityManifestSchema,
543
- })
544
- .strict();
604
+ /** Like the manifest, catalog entries, pages, and their pagination ignore fields that a newer Cloud release added. */
605
+ export const CapabilityCatalogAppSchema = z.object({
606
+ appId: CapabilityAppIdSchema,
607
+ appName: z.string().min(1).max(200),
608
+ appIcon: z.string().min(1).max(120),
609
+ appDescription: z.string().max(1000),
610
+ manifest: CapabilityManifestSchema,
611
+ });
545
612
 
546
- export const CapabilityCatalogSchema = z
547
- .object({
548
- protocolVersion: z.literal(CAPABILITY_PROTOCOL_VERSION),
549
- apps: z.array(CapabilityCatalogAppSchema).max(25),
550
- page: CapabilityPageSchema,
551
- })
552
- .strict();
613
+ export const CapabilityCatalogSchema = z.object({
614
+ protocolVersion: z.literal(CAPABILITY_PROTOCOL_VERSION),
615
+ apps: z.array(CapabilityCatalogAppSchema).max(25),
616
+ page: z.discriminatedUnion("hasMore", [CapabilityPageSchema.options[0].strip(), CapabilityPageSchema.options[1].strip()]),
617
+ });
553
618
 
554
619
  export type CapabilityCatalog = z.infer<typeof CapabilityCatalogSchema>;
@@ -8,6 +8,8 @@ import type { CapabilityActionManifest, CapabilityQueryManifest } from "./capabi
8
8
  export type CapabilityContract = {
9
9
  kind: "query" | "action";
10
10
  idempotency?: "required";
11
+ /** The binary stream direction the operation must declare. Without it, streaming operations do not match. */
12
+ stream?: "read" | "write";
11
13
  input: z.ZodType;
12
14
  data: z.ZodType;
13
15
  };
@@ -102,6 +104,20 @@ const patternMatches = (pattern: string, value: string): boolean => {
102
104
  }
103
105
  };
104
106
 
107
+ type Bound = { value: number; exclusive: boolean };
108
+ const lowerBound = (schema: Json): Bound | null =>
109
+ typeof schema.exclusiveMinimum === "number"
110
+ ? { value: schema.exclusiveMinimum, exclusive: true }
111
+ : typeof schema.minimum === "number"
112
+ ? { value: schema.minimum, exclusive: false }
113
+ : null;
114
+ const upperBound = (schema: Json): Bound | null =>
115
+ typeof schema.exclusiveMaximum === "number"
116
+ ? { value: schema.exclusiveMaximum, exclusive: true }
117
+ : typeof schema.maximum === "number"
118
+ ? { value: schema.maximum, exclusive: false }
119
+ : null;
120
+
105
121
  /** Checks one literal value against a provider or contract schema. Unknown keywords never pass. */
106
122
  const valueIssue = (value: unknown, sup: Side, depth: number): string | null => {
107
123
  const resolved = deref(sup, depth);
@@ -126,22 +142,15 @@ const valueIssue = (value: unknown, sup: Side, depth: number): string | null =>
126
142
  if (typeof schema.maxLength === "number" && value.length > schema.maxLength) return "value is too long";
127
143
  if (typeof schema.pattern === "string" && !patternMatches(schema.pattern, value)) return "value does not match the pattern";
128
144
  }
145
+ if (typeof value === "number") {
146
+ const lower = lowerBound(schema);
147
+ const upper = upperBound(schema);
148
+ if (lower && (lower.exclusive ? value <= lower.value : value < lower.value)) return `value ${value} is out of range`;
149
+ if (upper && (upper.exclusive ? value >= upper.value : value > upper.value)) return `value ${value} is out of range`;
150
+ }
129
151
  return null;
130
152
  };
131
153
 
132
- type Bound = { value: number; exclusive: boolean };
133
- const lowerBound = (schema: Json): Bound | null =>
134
- typeof schema.exclusiveMinimum === "number"
135
- ? { value: schema.exclusiveMinimum, exclusive: true }
136
- : typeof schema.minimum === "number"
137
- ? { value: schema.minimum, exclusive: false }
138
- : null;
139
- const upperBound = (schema: Json): Bound | null =>
140
- typeof schema.exclusiveMaximum === "number"
141
- ? { value: schema.exclusiveMaximum, exclusive: true }
142
- : typeof schema.maximum === "number"
143
- ? { value: schema.maximum, exclusive: false }
144
- : null;
145
154
  const withinLower = (sub: Bound | null, sup: Bound | null): boolean =>
146
155
  !sup || (sub !== null && (sub.value > sup.value || (sub.value === sup.value && (sub.exclusive || !sup.exclusive))));
147
156
  const withinUpper = (sub: Bound | null, sup: Bound | null): boolean =>
@@ -356,7 +365,12 @@ export const capabilityContractIssues = (
356
365
  return [{ code: "kind", path: "$", message: `Expected ${contract.kind === "query" ? "a Query" : "an Action"}` }];
357
366
  }
358
367
  const issues: CapabilityContractIssue[] = [];
359
- if (candidate.operation.stream) issues.push({ code: "stream", path: "$", message: "Streaming capabilities are not supported" });
368
+ const direction = candidate.operation.stream?.direction;
369
+ if (contract.stream && direction !== contract.stream) {
370
+ issues.push({ code: "stream", path: "$", message: `Expected a ${contract.stream} stream` });
371
+ } else if (!contract.stream && direction) {
372
+ issues.push({ code: "stream", path: "$", message: "Streaming capabilities are not supported" });
373
+ }
360
374
  if (contract.idempotency === "required" && candidate.kind === "action" && candidate.operation.idempotency !== "required") {
361
375
  issues.push({ code: "idempotency", path: "$", message: "The Action must require an idempotency key" });
362
376
  }
@@ -0,0 +1,158 @@
1
+ import { z } from "zod";
2
+ import type { CapabilityManifest } from "./capabilities";
3
+ import { type CapabilityContract, type CapabilityContractIssue, capabilityContractIssues } from "./capability-compatibility";
4
+
5
+ /**
6
+ * Provider-neutral file-provider contract.
7
+ *
8
+ * An application offers files to other applications by declaring
9
+ * `fileProvider: { list, read, save? }` in `defineCapabilities`. Each entry
10
+ * names one of its own Queries or Actions; the schemas below are the minimum
11
+ * both sides share. Identifiers and cursors are opaque provider strings, and
12
+ * the provider authorizes every call with the caller's own access.
13
+ */
14
+
15
+ // Bounds follow the shared provider page: 100 entries, 3 tags of 40 characters.
16
+ const ProviderIdSchema = z.string().min(1).max(2048);
17
+ const CursorSchema = z.string().min(1).max(16384);
18
+ const TimestampSchema = z.string().datetime({ offset: true });
19
+
20
+ /** One display chip. Tags describe an entry; they are not a filter or a permission. */
21
+ export const FileProviderTagSchema = z
22
+ .object({
23
+ label: z.string().min(1).max(40),
24
+ tone: z.enum(["neutral", "info", "success", "warning", "danger"]).optional(),
25
+ })
26
+ .strict();
27
+
28
+ export const FileProviderListInputSchema = z
29
+ .object({
30
+ parent: ProviderIdSchema.optional().describe("Folder ID returned by this provider; omit for the provider root."),
31
+ query: z.string().max(200).optional().describe("Optional name filter inside the folder."),
32
+ cursor: CursorSchema.optional().describe("Opaque cursor returned by the previous page; keep parent, query, and limit unchanged."),
33
+ limit: z.number().int().min(1).max(100).default(50).describe("Maximum number of entries to return."),
34
+ })
35
+ .strict();
36
+
37
+ const entryFacts = {
38
+ id: ProviderIdSchema,
39
+ name: z.string().min(1).max(255),
40
+ updatedAt: TimestampSchema.optional(),
41
+ icon: z.string().min(1).max(120).optional(),
42
+ tags: z.array(FileProviderTagSchema).max(3).optional(),
43
+ };
44
+
45
+ export const FileProviderEntrySchema = z.discriminatedUnion("kind", [
46
+ z.object({ kind: z.literal("folder"), ...entryFacts }).loose(),
47
+ z
48
+ .object({
49
+ kind: z.literal("file"),
50
+ ...entryFacts,
51
+ size: z.number().int().min(0),
52
+ mediaType: z.string().min(1).max(255).optional(),
53
+ })
54
+ .loose(),
55
+ ]);
56
+
57
+ /** One folder page. Folders may be virtual; pages may be short or empty and still continue while `next` is set. */
58
+ export const FileProviderListDataSchema = z
59
+ .object({
60
+ writable: z.boolean(),
61
+ items: z.array(FileProviderEntrySchema).max(100),
62
+ next: CursorSchema.nullable(),
63
+ })
64
+ .loose();
65
+
66
+ export const FileProviderReadInputSchema = z
67
+ .object({ id: ProviderIdSchema.describe("File ID returned by this provider's list.") })
68
+ .strict();
69
+ export const FileProviderReadDataSchema = z.object({}).loose();
70
+
71
+ /** A single file name: no path separators, control characters, `.` or `..`. */
72
+ const FileNameSchema = z
73
+ .string()
74
+ .min(1)
75
+ .max(255)
76
+ .regex(/^(?!\.{1,2}$)[^/\\\u0000-\u001f\u007f]+$/);
77
+
78
+ export const FileProviderSaveInputSchema = z
79
+ .object({
80
+ parent: ProviderIdSchema.describe("Writable folder ID returned by this provider's list."),
81
+ name: FileNameSchema.describe("Name of the new file."),
82
+ mediaType: z
83
+ .string()
84
+ .min(1)
85
+ .max(255)
86
+ .regex(/^[\x20-\x7e]+$/)
87
+ .describe("Media type of the content."),
88
+ size: z.number().int().min(0).describe("Exact byte count of the content."),
89
+ })
90
+ .strict();
91
+
92
+ /** `file` is present once the write stream completed; the call that opens the stream has no file yet. */
93
+ export const FileProviderSaveDataSchema = z
94
+ .object({
95
+ file: z
96
+ .object({ id: ProviderIdSchema, name: z.string().min(1).max(255), size: z.number().int().min(0) })
97
+ .loose()
98
+ .optional(),
99
+ })
100
+ .loose();
101
+
102
+ /**
103
+ * The error code `save` answers, with status `409`, when the name already
104
+ * exists in the folder. Consumers ask for another name only on this code;
105
+ * every other failure keeps its own code, even with status `409`.
106
+ */
107
+ export const FILE_PROVIDER_NAME_CONFLICT = "FILE_NAME_CONFLICT";
108
+
109
+ /**
110
+ * The three file-provider functions. `read` streams the file's bytes, `save`
111
+ * is an idempotent Action with a write stream that only creates files: an
112
+ * existing name fails with `FILE_PROVIDER_NAME_CONFLICT`.
113
+ */
114
+ export const fileProvider = {
115
+ list: { kind: "query", input: FileProviderListInputSchema, data: FileProviderListDataSchema },
116
+ read: { kind: "query", input: FileProviderReadInputSchema, data: FileProviderReadDataSchema, stream: "read" },
117
+ save: {
118
+ kind: "action",
119
+ idempotency: "required",
120
+ input: FileProviderSaveInputSchema,
121
+ data: FileProviderSaveDataSchema,
122
+ stream: "write",
123
+ },
124
+ } as const satisfies Record<string, CapabilityContract>;
125
+
126
+ export type FileProviderFunction = keyof typeof fileProvider;
127
+ export const FILE_PROVIDER_FUNCTIONS = ["list", "read", "save"] as const satisfies readonly FileProviderFunction[];
128
+
129
+ export type FileProviderTag = z.output<typeof FileProviderTagSchema>;
130
+ export type FileProviderEntry = z.output<typeof FileProviderEntrySchema>;
131
+ export type FileProviderListData = z.output<typeof FileProviderListDataSchema>;
132
+ export type FileProviderSaveData = z.output<typeof FileProviderSaveDataSchema>;
133
+
134
+ export type FileProviderIssue = CapabilityContractIssue & { function: FileProviderFunction; localId: string };
135
+
136
+ /**
137
+ * Explains why a manifest's `fileProvider` declaration cannot serve the
138
+ * contract. Apps run it at start and fail; Core runs it when it reads a live
139
+ * manifest and ignores an invalid declaration. An empty list means valid or
140
+ * not declared.
141
+ */
142
+ export const fileProviderIssues = (manifest: CapabilityManifest): FileProviderIssue[] => {
143
+ const declaration = manifest.fileProvider;
144
+ if (!declaration) return [];
145
+ return FILE_PROVIDER_FUNCTIONS.flatMap((name) => {
146
+ const localId = declaration[name];
147
+ if (localId === undefined) return [];
148
+ const contract = fileProvider[name];
149
+ const query = manifest.queries.find((operation) => operation.localId === localId);
150
+ const action = manifest.actions.find((operation) => operation.localId === localId);
151
+ const issues = query
152
+ ? capabilityContractIssues(contract, { kind: "query", operation: query })
153
+ : action
154
+ ? capabilityContractIssues(contract, { kind: "action", operation: action })
155
+ : [{ code: "kind" as const, path: "$", message: `No Query or Action named ${localId}` }];
156
+ return issues.map((issue) => ({ ...issue, function: name, localId }));
157
+ });
158
+ };
@@ -6,6 +6,7 @@ export * from "./capabilities";
6
6
  export * from "./capability-compatibility";
7
7
  export * from "./commands";
8
8
  export * from "./contact-directory";
9
+ export * from "./file-provider";
9
10
  export * from "./notification-types";
10
11
  export * from "./posix";
11
12
  export * from "./profile";
@@ -164,6 +164,8 @@ export const createLiveEngine = (input: { appId: string; topic: () => LiveTopic;
164
164
  const inbox: Entry[] = [];
165
165
  let draining = false;
166
166
  let room: (() => void) | null = null;
167
+ /** Sequence of the latest access update the follower read, already while it waits in the inbox or is checked. */
168
+ let lastAccess = 0;
167
169
 
168
170
  const cursorAt = (seq: number) => input.topic().cursorAt(seq);
169
171
  const frame = {
@@ -450,6 +452,7 @@ export const createLiveEngine = (input: { appId: string; topic: () => LiveTopic;
450
452
  return { ...base, key: null, data: undefined, resync: false, access: false, bytes: 64 };
451
453
  }
452
454
  const { k, d, r, a } = parsed.data;
455
+ if (a === true) lastAccess = event.sequence;
453
456
  const data = d === undefined ? undefined : JSON.stringify(d);
454
457
  return { ...base, key: k, data, resync: r === true, access: a === true, bytes: (data?.length ?? 0) + k.length + 64 };
455
458
  };
@@ -679,6 +682,7 @@ export const createLiveEngine = (input: { appId: string; topic: () => LiveTopic;
679
682
  const scope = channel.scope.safeParse(request.scope);
680
683
  if (!scope.success) return violation(conn, "invalid_scope");
681
684
  await whenStarted();
685
+ const accessBefore = lastAccess;
682
686
  const keys = await channel.keys(scope.data, conn.viewer);
683
687
  const readable = keys === null ? [] : await readableKeys(request.channel, followed(request.channel, keys), conn.viewer);
684
688
  // Not found and not readable look the same.
@@ -692,13 +696,13 @@ export const createLiveEngine = (input: { appId: string; topic: () => LiveTopic;
692
696
  // the replay covers everything up to it, live delivery everything after it.
693
697
  const position = Math.max(head, ring.at(-1)?.seq ?? 0);
694
698
  const oldest = ring[0]?.seq ?? position + 1;
695
- // A collection cannot tell which keys it had before: an access update of a key it follows now,
696
- // missed while it was away, may have added that key.
697
- const missedAccess =
698
- channel.collection === true &&
699
- after !== null &&
700
- ring.some((entry) => entry.access && entry.seq > after && entry.seq <= position && readable.includes(entry.key as string));
701
- const resync = request.after !== undefined && (after === null || after < oldest - 1 || recreated || missedAccess);
699
+ // A collection cannot tell which keys it had at its cursor: any access update it missed may have
700
+ // added one of its keys now or removed one it had then, and only `resync` drops a removed one.
701
+ const missedAccess = channel.collection === true && after !== null && lastAccess > after;
702
+ // An access update read while `keys()` and `authorize` answered may postdate their answers,
703
+ // which must not release a replay of what followed it.
704
+ const accessMeanwhile = lastAccess !== accessBefore;
705
+ const resync = request.after !== undefined && (after === null || after < oldest - 1 || recreated || missedAccess || accessMeanwhile);
702
706
  const sub: Subscription = {
703
707
  id: request.id,
704
708
  conn,
@@ -61,13 +61,14 @@ export const liveOutbox = (appId: string, publish: (row: LiveOutboxRow) => Promi
61
61
  where: { kind: "live", app_id: appId },
62
62
  orderBy: "ordering_key",
63
63
  sequence: "seq",
64
- onDelivered: "delete",
65
64
  reconcileIntervalMs: RECONCILE_INTERVAL_MS,
66
65
  publish,
67
66
  });
68
67
 
69
68
  /** Applications whose live updates this process defines, with the wake of their running dispatcher. */
70
69
  const dispatchers = new Map<string, (() => void) | null>();
70
+ /** Applications whose live socket this process mounts; only the started application may be among them. */
71
+ const served = new Set<string>();
71
72
  /** Live sockets served by this process; they close when the application stops. */
72
73
  const engines = new Set<LiveEngine>();
73
74
  /** Set when the application stops delivery: its engines stay stopped until delivery starts again. */
@@ -89,26 +90,27 @@ const logStaleRows = async (appId: string): Promise<void> => {
89
90
  };
90
91
 
91
92
  /**
92
- * Publishes the rows of the live definitions in this process. `app.start()`
93
- * calls it with the started application's ID; it does nothing without a
94
- * definition, and fails when a definition names another application or Core
95
- * has not created the outbox yet.
93
+ * Publishes the rows of the started application's live definitions. `app.start()`
94
+ * calls it with the started application's ID. A definition of another
95
+ * application only reads that application's cursor, and its rows are published
96
+ * by that application. It does nothing without a definition of the started
97
+ * application, and fails when this process mounts the socket of another
98
+ * application or Core has not created the outbox yet.
96
99
  */
97
- export const startLiveOutbox = async (startedAppId: string): Promise<(() => Promise<void>) | null> => {
98
- const appIds = [...dispatchers.keys()];
99
- if (appIds.length === 0) return null;
100
- const foreign = appIds.filter((appId) => appId !== startedAppId);
100
+ export const startLiveOutbox = async (appId: string): Promise<(() => Promise<void>) | null> => {
101
+ const foreign = [...served].filter((other) => other !== appId);
101
102
  if (foreign.length > 0) {
102
103
  throw new Error(
103
- `defineLive() names "${foreign.join('", "')}", but this process starts "${startedAppId}". Use the ID from the application's declaration.`,
104
+ `This process mounts the live socket of "${foreign.join('", "')}", but starts "${appId}". Use the ID from the application's declaration.`,
104
105
  );
105
106
  }
107
+ if (!dispatchers.has(appId)) return null;
106
108
  const [installed] = await sql<{ ready: boolean }[]>`
107
109
  SELECT to_regprocedure('events.enqueue(uuid,text,text,text,jsonb,text)') IS NOT NULL AS ready
108
110
  `;
109
111
  if (!installed?.ready) {
110
112
  throw new Error(
111
- `"${appIds.join('", "')}" writes live updates to events.outbox, which does not exist. Update Cloud Core first: its migration creates the outbox.`,
113
+ `"${appId}" writes live updates to events.outbox, which does not exist. Update Cloud Core first: its migration creates the outbox.`,
112
114
  );
113
115
  }
114
116
  // Delivery starts again: the engines stopped before give way to new ones.
@@ -116,26 +118,21 @@ export const startLiveOutbox = async (startedAppId: string): Promise<(() => Prom
116
118
  engines.clear();
117
119
  stopped = false;
118
120
  }
119
- const stops = appIds.map((appId) => {
120
- const topic = liveTopics()(appId);
121
- const outbox = liveOutbox(appId, (row) => topic.publish({ data: row.payload, orderingKey: row.ordering_key, idempotencyKey: row.id }));
122
- outbox.start();
123
- dispatchers.set(appId, () => void outbox.notify());
124
- const staleCheck = setInterval(() => {
125
- logStaleRows(appId).catch((error) =>
126
- log.warn("Live outbox check failed", { appId, error: error instanceof Error ? error.message : String(error) }),
127
- );
128
- }, STALE_CHECK_INTERVAL_MS);
129
- staleCheck.unref();
130
- return async () => {
131
- clearInterval(staleCheck);
132
- dispatchers.set(appId, null);
133
- await outbox.stop();
134
- };
135
- });
121
+ const topic = liveTopics()(appId);
122
+ const outbox = liveOutbox(appId, (row) => topic.publish({ data: row.payload, orderingKey: row.ordering_key, idempotencyKey: row.id }));
123
+ outbox.start();
124
+ dispatchers.set(appId, () => void outbox.notify());
125
+ const staleCheck = setInterval(() => {
126
+ logStaleRows(appId).catch((error) =>
127
+ log.warn("Live outbox check failed", { appId, error: error instanceof Error ? error.message : String(error) }),
128
+ );
129
+ }, STALE_CHECK_INTERVAL_MS);
130
+ staleCheck.unref();
136
131
  return async () => {
137
132
  stopLiveEngines();
138
- await Promise.all(stops.map((stop) => stop()));
133
+ clearInterval(staleCheck);
134
+ dispatchers.set(appId, null);
135
+ await outbox.stop();
139
136
  };
140
137
  };
141
138
 
@@ -215,8 +212,9 @@ const refusalOf = async (c: Context<AuthContext>): Promise<Refusal | null> => {
215
212
  * Live updates of one application: hints, optionally with data, for its own
216
213
  * open tabs. Define them once at module scope; `app.start()` then publishes the
217
214
  * rows that `publish()` writes, and `routes()` serves them to browsers.
218
- * `appId` is required and must be the ID that the process starts; it is never
219
- * derived from the process.
215
+ * `appId` is required and is never derived from the process. Another
216
+ * application's process may hold the definition to read its `cursor()` or
217
+ * write updates; only the application itself publishes and serves them.
220
218
  */
221
219
  export const defineLive = <const Event extends z.ZodType>(definition: { appId: string; event: Event }) => {
222
220
  const { appId, event } = definition;
@@ -250,7 +248,8 @@ export const defineLive = <const Event extends z.ZodType>(definition: { appId: s
250
248
  /** The topic head. Read it before loading the snapshot it belongs to. */
251
249
  cursor: (): Promise<string> => liveTopics()(appId).head(),
252
250
  /**
253
- * The live socket with these channels. Mount it once, at `/api/<app>/live`.
251
+ * The live socket with these channels. Mount it once, at `/api/<app>/live`,
252
+ * in the application's own process: `app.start()` of another application fails.
254
253
  * It authenticates like any API request; a session needs the Cloud origin
255
254
  * and an OAuth token the `read` scope. A refused socket receives `error`
256
255
  * and closes with 1008. Channels are passed here, not to
@@ -258,6 +257,7 @@ export const defineLive = <const Event extends z.ZodType>(definition: { appId: s
258
257
  * themselves publish updates.
259
258
  */
260
259
  routes: <const Scopes extends Record<string, z.ZodType>>(channels: { [Name in keyof Scopes]: LiveChannel<Scopes[Name]> }) => {
260
+ served.add(appId);
261
261
  let engine: LiveEngine | null = null;
262
262
  const serve = (): LiveEngine => {
263
263
  if (engine && engines.has(engine)) return engine;
@@ -35,7 +35,7 @@ export type HelpCollection = {
35
35
 
36
36
  type ParsedHelpDocument = Omit<HelpDefinitionDocument, "order"> & { order?: number };
37
37
 
38
- const parseSource = (source: string): ParsedHelpDocument => {
38
+ const parseSource = (source: string, locale: string): ParsedHelpDocument => {
39
39
  const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/.exec(source);
40
40
  if (!match) throw new Error("Help documents require YAML frontmatter wrapped in --- markers");
41
41
 
@@ -46,7 +46,7 @@ const parseSource = (source: string): ParsedHelpDocument => {
46
46
  return {
47
47
  ...metadata,
48
48
  markdown,
49
- html: renderHelpMarkdown(markdown),
49
+ html: renderHelpMarkdown(markdown, locale),
50
50
  searchText: markdownToPlainText(markdown),
51
51
  };
52
52
  };
@@ -65,7 +65,7 @@ const parseDocuments = (
65
65
  baseById?: ReadonlyMap<string, HelpDefinitionDocument>,
66
66
  ): readonly HelpDefinitionDocument[] => {
67
67
  const documents = sources
68
- .map(parseSource)
68
+ .map((source) => parseSource(source, locale))
69
69
  .map((document): HelpDefinitionDocument => {
70
70
  const base = baseById?.get(document.id);
71
71
  if (!baseById) return { ...document, order: document.order ?? 100 };
@@ -197,7 +197,7 @@ export type {
197
197
  export { latestTopicCursor } from "./topic-cursor";
198
198
  export { readAccountCategoryPolicy, isAccountCategoryAllowed } from "./account-category-policy";
199
199
  export { AppApprovalError, appApproval, type AppDeviceAdministrator, type AppDeviceEnrollmentNotice } from "./app-approval";
200
- export { PwaError, pwaDevices, type PwaDeviceAdministrator } from "./pwa-devices";
200
+ export { PwaError, pwaDevices, type PwaDeviceAdministrator, type PwaDevicePairedNotice } from "./pwa-devices";
201
201
  export { legalConsent } from "./legal-consent";
202
202
 
203
203
  /** Core-owned app bar administration; service methods enforce administrator access. */