@procedures/store 0.44.0 → 0.45.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 (52) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +63 -0
  3. package/dist/.tsbuildinfo +1 -1
  4. package/dist/indexeddb/idb.d.ts +2 -1
  5. package/dist/indexeddb/idb.d.ts.map +1 -1
  6. package/dist/indexeddb/idb.js +2 -1
  7. package/dist/indexeddb/idb.js.map +1 -1
  8. package/dist/indexeddb/program-store.d.ts +7 -3
  9. package/dist/indexeddb/program-store.d.ts.map +1 -1
  10. package/dist/indexeddb/program-store.js +28 -6
  11. package/dist/indexeddb/program-store.js.map +1 -1
  12. package/dist/indexeddb.d.ts +1 -1
  13. package/dist/indexeddb.d.ts.map +1 -1
  14. package/dist/program-store/collection.d.ts +13 -4
  15. package/dist/program-store/collection.d.ts.map +1 -1
  16. package/dist/program-store/collection.js +29 -6
  17. package/dist/program-store/collection.js.map +1 -1
  18. package/dist/program-store/file.d.ts +7 -3
  19. package/dist/program-store/file.d.ts.map +1 -1
  20. package/dist/program-store/file.js +18 -5
  21. package/dist/program-store/file.js.map +1 -1
  22. package/dist/program-store/index.d.ts +4 -2
  23. package/dist/program-store/index.d.ts.map +1 -1
  24. package/dist/program-store/index.js +2 -1
  25. package/dist/program-store/index.js.map +1 -1
  26. package/dist/program-store/memory.d.ts +7 -4
  27. package/dist/program-store/memory.d.ts.map +1 -1
  28. package/dist/program-store/memory.js +12 -5
  29. package/dist/program-store/memory.js.map +1 -1
  30. package/dist/program-store/procedures.d.ts +6 -1
  31. package/dist/program-store/procedures.d.ts.map +1 -1
  32. package/dist/program-store/procedures.js +4 -2
  33. package/dist/program-store/procedures.js.map +1 -1
  34. package/dist/program-store/programs.d.ts +110 -0
  35. package/dist/program-store/programs.d.ts.map +1 -0
  36. package/dist/program-store/programs.js +202 -0
  37. package/dist/program-store/programs.js.map +1 -0
  38. package/dist/program-store/types.d.ts +53 -4
  39. package/dist/program-store/types.d.ts.map +1 -1
  40. package/dist/program-store/types.js +51 -0
  41. package/dist/program-store/types.js.map +1 -1
  42. package/package.json +4 -4
  43. package/src/indexeddb/idb.ts +3 -2
  44. package/src/indexeddb/program-store.ts +36 -6
  45. package/src/indexeddb.ts +1 -1
  46. package/src/program-store/collection.ts +39 -6
  47. package/src/program-store/file.ts +30 -5
  48. package/src/program-store/index.ts +12 -2
  49. package/src/program-store/memory.ts +20 -5
  50. package/src/program-store/procedures.ts +4 -2
  51. package/src/program-store/programs.ts +307 -0
  52. package/src/program-store/types.ts +85 -4
@@ -5,10 +5,23 @@
5
5
  * as the key of its record. IndexedDB keeps a structured clone of each record, so a caller that
6
6
  * changes a result does not change the store. The store checks each record that it reads: the
7
7
  * database of a browser can hold records of an older version or of another application.
8
+ *
9
+ * The version of a record is a counter. Each put gives one more than the highest version in the
10
+ * store, in the transaction of the write. The database has no field for a counter. Thus after a
11
+ * delete of the record with the highest version, a later put can give that version again.
8
12
  */
9
13
 
10
- import { byKey, checkStoredProgram, keyMatches, type ProgramStore, type StoredProgram } from "../program-store/types.js";
11
- import { IdbStoreConnection, type IndexedDbOptions } from "./idb.js";
14
+ import {
15
+ byKey,
16
+ checkProgramVersion,
17
+ checkStoredProgram,
18
+ keyMatches,
19
+ nextCounterVersion,
20
+ type ProgramStore,
21
+ type ProgramWriteOptions,
22
+ type StoredProgram,
23
+ } from "../program-store/types.js";
24
+ import { IdbStoreConnection, onResult, type IndexedDbOptions } from "./idb.js";
12
25
 
13
26
  /** The default name of the database of the programs. */
14
27
  export const DEFAULT_PROGRAM_DATABASE = "procedures-programs";
@@ -56,15 +69,32 @@ export class IndexedDbProgramStore implements ProgramStore {
56
69
  });
57
70
  }
58
71
 
59
- async put(program: StoredProgram): Promise<void> {
72
+ async put(program: StoredProgram, options?: ProgramWriteOptions): Promise<void> {
60
73
  const checked = checkStoredProgram(structuredClone(program));
61
- await this.connection.run("readwrite", (store) => {
62
- store.put(checked);
74
+ await this.connection.run("readwrite", (store, fail) => {
75
+ // The read, the test and the write are one transaction: another tab cannot write between them
76
+ onResult(store.getAll(), fail, (records) => {
77
+ const programs = records.map((record) => checkStoredProgram(record));
78
+ checkProgramVersion(checked.key, programs.find((current) => current.key === checked.key), options);
79
+ store.put({ ...checked, version: nextCounterVersion(0, programs) });
80
+ });
63
81
  return () => undefined;
64
82
  });
65
83
  }
66
84
 
67
- delete(key: string): Promise<boolean> {
85
+ delete(key: string, options?: ProgramWriteOptions): Promise<boolean> {
86
+ if (options?.expectedVersion !== undefined) {
87
+ return this.connection.run("readwrite", (store, fail) => {
88
+ let existed = false;
89
+ onResult(store.get(key), fail, (record) => {
90
+ const current = record === undefined ? undefined : checkStoredProgram(record);
91
+ checkProgramVersion(key, current, options);
92
+ existed = current !== undefined;
93
+ if (existed) store.delete(key);
94
+ });
95
+ return () => existed;
96
+ });
97
+ }
68
98
  return this.connection.run("readwrite", (store) => {
69
99
  // The requests of one transaction run in order: the count sees the record before the delete
70
100
  const count = store.count(key);
package/src/indexeddb.ts CHANGED
@@ -25,5 +25,5 @@ export { MemoryProgramStore } from "./program-store/memory.js";
25
25
  export { MemoryQueueStore } from "./storage/queue.js";
26
26
  export { MemorySyncStateStore } from "./storage/sync-state.js";
27
27
  export type { SyncState, SyncStateChange, SyncStateStore } from "./storage/sync-state.js";
28
- export type { ProgramStore, StoredProgram } from "./program-store/types.js";
28
+ export type { ProgramStore, ProgramWriteOptions, StoredProgram } from "./program-store/types.js";
29
29
  export type { QueuedOperation, QueueStore } from "./storage/queue.js";
@@ -2,15 +2,43 @@
2
2
  * A program store on a collection storage: `InMemoryStorage` of client-collections, or a remote
3
3
  * one, `ApiStorage` (the collection procedures of a server) or `HybridStorage` (a local cache with
4
4
  * a remote copy). The key of each record is the key of the program.
5
+ *
6
+ * The version of a record is a revision string: a counter and a random part, for example
7
+ * `"3-k2j9x0aq"`. The storage tests `expectedVersion` itself, with its own `versionOf`, in one
8
+ * step with the write. So give the storage the option `versionOf: storedProgramVersion`. A storage
9
+ * without `versionOf` rejects a write with `expectedVersion` (a `TypeError`). It accepts a write
10
+ * without `expectedVersion`.
5
11
  */
6
12
 
7
13
  import type { CollectionStorage } from "@procedures/client";
8
- import { byKey, checkStoredProgram, keyMatches, type ProgramStore, type StoredProgram } from "./types.js";
14
+ import {
15
+ byKey,
16
+ checkStoredProgram,
17
+ expectedVersionOf,
18
+ keyMatches,
19
+ type ProgramStore,
20
+ type ProgramWriteOptions,
21
+ type StoredProgram,
22
+ } from "./types.js";
23
+
24
+ /** The counter of a revision string: the digits before the dash, or 0. */
25
+ function revisionCounter(version: string | number | null | undefined): number {
26
+ const counter = Number.parseInt(String(version ?? "0"), 10);
27
+ return Number.isSafeInteger(counter) && counter > 0 ? counter : 0;
28
+ }
29
+
30
+ /** A new revision string after a version. The random part makes two writes of one base differ. */
31
+ function nextRevision(version: string | number | null | undefined): string {
32
+ return `${revisionCounter(version) + 1}-${Math.random().toString(36).slice(2, 10).padEnd(8, "0")}`;
33
+ }
9
34
 
10
35
  export class CollectionProgramStore implements ProgramStore {
11
36
  readonly kind = "collection";
12
37
 
13
- /** @param storage - The storage of the records. The store closes it in `close()`. */
38
+ /**
39
+ * @param storage - The storage of the records. The store closes it in `close()`. Give it the
40
+ * option `versionOf: storedProgramVersion`, so that it can test `expectedVersion`.
41
+ */
14
42
  constructor(private readonly storage: CollectionStorage<StoredProgram>) {}
15
43
 
16
44
  async get(key: string): Promise<StoredProgram | undefined> {
@@ -24,13 +52,18 @@ export class CollectionProgramStore implements ProgramStore {
24
52
  return byKey(records.map((record) => checkStoredProgram(structuredClone(record))).filter((p) => keyMatches(p.key, prefix)));
25
53
  }
26
54
 
27
- async put(program: StoredProgram): Promise<void> {
55
+ async put(program: StoredProgram, options?: ProgramWriteOptions): Promise<void> {
28
56
  const checked = checkStoredProgram(structuredClone(program));
29
- await this.storage.set(checked.key, checked);
57
+ const expected = expectedVersionOf(options);
58
+ // The expected version is the base of the write. Without it, the current record is the base.
59
+ const base = expected !== undefined ? expected : (await this.storage.get(checked.key))?.version;
60
+ const record: StoredProgram = { ...checked, version: nextRevision(base) };
61
+ await this.storage.set(checked.key, record, expected === undefined ? undefined : { expectedVersion: expected });
30
62
  }
31
63
 
32
- async delete(key: string): Promise<boolean> {
33
- return this.storage.delete(key);
64
+ async delete(key: string, options?: ProgramWriteOptions): Promise<boolean> {
65
+ const expected = expectedVersionOf(options);
66
+ return expected === undefined ? this.storage.delete(key) : this.storage.delete(key, { expectedVersion: expected });
34
67
  }
35
68
 
36
69
  async close(): Promise<void> {
@@ -6,14 +6,29 @@
6
6
  * file (`<file>.lock`), reads, changes and writes the file through a temporary file and a rename,
7
7
  * so a reader never sees a part of a write and two writers do not lose each other's programs.
8
8
  * A file that is not valid JSON is an error: the store does not write over it.
9
+ *
10
+ * The version of a record is a counter of the file, in the optional field `counter`. Each put takes
11
+ * the next number under the lock, and tests `expectedVersion` under the same lock. A file of an
12
+ * older release has no counter: the highest version of its records starts the count.
9
13
  */
10
14
 
11
15
  import { mkdir, open, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
12
16
  import { dirname } from "node:path";
13
- import { byKey, checkStoredProgram, keyMatches, type ProgramStore, type StoredProgram } from "./types.js";
17
+ import {
18
+ byKey,
19
+ checkProgramVersion,
20
+ checkStoredProgram,
21
+ keyMatches,
22
+ nextCounterVersion,
23
+ type ProgramStore,
24
+ type ProgramWriteOptions,
25
+ type StoredProgram,
26
+ } from "./types.js";
14
27
 
15
28
  interface ProgramFile {
16
29
  schemaVersion: 1;
30
+ /** The last version that a put gave. A file of an older release does not have it. */
31
+ counter?: number;
17
32
  programs: Record<string, StoredProgram>;
18
33
  }
19
34
 
@@ -34,6 +49,11 @@ function isContention(error: unknown): boolean {
34
49
 
35
50
  const pause = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
36
51
 
52
+ /** The program of a key, or undefined. A key such as "constructor" is not a field of the prototype. */
53
+ function ownProgram(file: ProgramFile, key: string): StoredProgram | undefined {
54
+ return Object.hasOwn(file.programs, key) ? file.programs[key] : undefined;
55
+ }
56
+
37
57
  /**
38
58
  * This function runs a file operation again while Windows holds the file for a moment. `FileQueueStore` uses it
39
59
  * too.
@@ -66,17 +86,21 @@ export class FileProgramStore implements ProgramStore {
66
86
  return byKey(Object.values((await this.read()).programs).filter((p) => keyMatches(p.key, prefix)));
67
87
  }
68
88
 
69
- async put(program: StoredProgram): Promise<void> {
89
+ async put(program: StoredProgram, options?: ProgramWriteOptions): Promise<void> {
70
90
  const checked = checkStoredProgram(structuredClone(program));
71
91
  await this.change((file) => {
72
- file.programs[checked.key] = checked;
92
+ checkProgramVersion(checked.key, ownProgram(file, checked.key), options);
93
+ const version = nextCounterVersion(file.counter ?? 0, Object.values(file.programs));
94
+ file.counter = version;
95
+ file.programs[checked.key] = { ...checked, version };
73
96
  return true;
74
97
  });
75
98
  }
76
99
 
77
- async delete(key: string): Promise<boolean> {
100
+ async delete(key: string, options?: ProgramWriteOptions): Promise<boolean> {
78
101
  let deleted = false;
79
102
  await this.change((file) => {
103
+ checkProgramVersion(key, ownProgram(file, key), options);
80
104
  deleted = Object.hasOwn(file.programs, key);
81
105
  if (deleted) delete file.programs[key];
82
106
  return deleted;
@@ -106,7 +130,8 @@ export class FileProgramStore implements ProgramStore {
106
130
  // A null-prototype object: a key such as "__proto__" stays a plain key
107
131
  const programs: Record<string, StoredProgram> = Object.create(null);
108
132
  for (const [key, value] of Object.entries(file.programs)) programs[key] = checkStoredProgram(value);
109
- return { schemaVersion: 1, programs };
133
+ const counter = typeof file.counter === "number" && Number.isSafeInteger(file.counter) && file.counter > 0 ? file.counter : undefined;
134
+ return counter === undefined ? { schemaVersion: 1, programs } : { schemaVersion: 1, counter, programs };
110
135
  }
111
136
 
112
137
  /** Read, change and write the file under the lock. `change` gives false when nothing changed. */
@@ -2,8 +2,8 @@
2
2
  * The program store (roadmap 3.2): programs of procedure.define that outlive the process.
3
3
  */
4
4
 
5
- export type { ProgramStore, StoredProgram } from "./types.js";
6
- export { checkStoredProgram } from "./types.js";
5
+ export type { ProgramStore, ProgramWriteOptions, StoredProgram } from "./types.js";
6
+ export { checkStoredProgram, storedProgramVersion } from "./types.js";
7
7
  export { MemoryProgramStore } from "./memory.js";
8
8
  export { FileProgramStore } from "./file.js";
9
9
  export { CollectionProgramStore } from "./collection.js";
@@ -15,3 +15,13 @@ export {
15
15
  procedureRemoteProcedure,
16
16
  programStoreProcedures,
17
17
  } from "./procedures.js";
18
+ export { createProgramModule, createProgramProcedures } from "./programs.js";
19
+ export type {
20
+ ProgramGetInput,
21
+ ProgramListInput,
22
+ ProgramProceduresOptions,
23
+ ProgramRemoveInput,
24
+ ProgramRemoveOutput,
25
+ ProgramSaveDefinition,
26
+ ProgramSaveInput,
27
+ } from "./programs.js";
@@ -1,14 +1,25 @@
1
1
  /**
2
2
  * A program store in memory: for tests, and for a process that keeps its programs only while it
3
3
  * runs. Each read and write copies the record, so a caller that changes a result does not change
4
- * the store.
4
+ * the store. The version of a record is a counter of the store: each put takes the next number.
5
+ * Thus a program that is deleted and written again does not get an old version back.
5
6
  */
6
7
 
7
- import { byKey, checkStoredProgram, keyMatches, type ProgramStore, type StoredProgram } from "./types.js";
8
+ import {
9
+ byKey,
10
+ checkProgramVersion,
11
+ checkStoredProgram,
12
+ keyMatches,
13
+ type ProgramStore,
14
+ type ProgramWriteOptions,
15
+ type StoredProgram,
16
+ } from "./types.js";
8
17
 
9
18
  export class MemoryProgramStore implements ProgramStore {
10
19
  readonly kind = "memory";
11
20
  private readonly programs = new Map<string, StoredProgram>();
21
+ /** The last version that a put gave. */
22
+ private counter = 0;
12
23
 
13
24
  async get(key: string): Promise<StoredProgram | undefined> {
14
25
  const program = this.programs.get(key);
@@ -19,12 +30,16 @@ export class MemoryProgramStore implements ProgramStore {
19
30
  return byKey([...this.programs.values()].filter((p) => keyMatches(p.key, prefix)).map((p) => structuredClone(p)));
20
31
  }
21
32
 
22
- async put(program: StoredProgram): Promise<void> {
33
+ async put(program: StoredProgram, options?: ProgramWriteOptions): Promise<void> {
23
34
  const checked = checkStoredProgram(structuredClone(program));
24
- this.programs.set(checked.key, checked);
35
+ // The test and the write are one synchronous step
36
+ checkProgramVersion(checked.key, this.programs.get(checked.key), options);
37
+ this.counter++;
38
+ this.programs.set(checked.key, { ...checked, version: this.counter });
25
39
  }
26
40
 
27
- async delete(key: string): Promise<boolean> {
41
+ async delete(key: string, options?: ProgramWriteOptions): Promise<boolean> {
42
+ checkProgramVersion(key, this.programs.get(key), options);
28
43
  return this.programs.delete(key);
29
44
  }
30
45
  }
@@ -35,11 +35,13 @@ import type { ProgramStore, StoredProgram } from "./types.js";
35
35
  // Shared
36
36
  // =============================================================================
37
37
 
38
- const pathField = z
38
+ /** The schema of a path: a list of segments, or a dotted key. `createProgramProcedures` uses it too. */
39
+ export const pathField: z.ZodUnion<readonly [z.ZodArray<z.ZodString>, z.ZodString]> = z
39
40
  .union([z.array(z.string().min(1)).min(1), z.string().min(1)])
40
41
  .describe('The path of the procedure: a list of segments, or a dotted key such as "reports.weekly"');
41
42
 
42
- function toPath(path: ProcedurePath | string): ProcedurePath {
43
+ /** This function gives the path of a path field: a dotted key becomes a list of segments. */
44
+ export function toPath(path: ProcedurePath | string): ProcedurePath {
43
45
  return typeof path === "string" ? keyToPath(path) : path;
44
46
  }
45
47
 
@@ -0,0 +1,307 @@
1
+ /**
2
+ * The procedures of the saved programs of a tenant (AG1-B): `programs.save`, `get`, `list` and
3
+ * `remove`.
4
+ *
5
+ * One program store has two scopes:
6
+ *
7
+ * - The scope "host": the store of a registry (`attachProgramStore`). `procedure.store`, `load` and
8
+ * `sync` use it, and `procedure.load` defines its programs in the registry.
9
+ * - The scope "tenant": the store of a partition. The procedures of this module read and write it.
10
+ * They do not define a program in a registry. A program of a tenant is data: a view lists it,
11
+ * and a caller starts it with `client.chain` or `client.exec`.
12
+ *
13
+ * The partition of each call comes from the resolver of the host, as for `createCollectionProcedures`.
14
+ * The resolver gets the context (with `ctx.principal`) and the partition that the input asks for.
15
+ * The input only selects: the resolver decides. A `null` result refuses the call (`FORBIDDEN`,
16
+ * reason `partition-not-allowed`). The function `storeFor` gives the store of the partition.
17
+ * Thus one tenant does not read the store of another tenant.
18
+ *
19
+ * A save tests the record with `checkStoredProgram`, and the program with the test of
20
+ * `procedure.define` (`isAnyProcedureRef`). A save and a remove take `expectedVersion` (decision
21
+ * D11): a conflict rejects with `VersionConflictError` and changes nothing.
22
+ */
23
+
24
+ import { z } from "zod";
25
+ import {
26
+ createProcedure,
27
+ defineModule,
28
+ InvocationError,
29
+ isAnyProcedureRef,
30
+ pathToKey,
31
+ PARTITION_PATTERN,
32
+ RAW_INPUT_TAG,
33
+ typed,
34
+ type AggregationDefinition,
35
+ type AnyProcedure,
36
+ type PartitionResolver,
37
+ type ProcedureContext,
38
+ type ProcedureModule,
39
+ type ProcedurePath,
40
+ } from "@procedures/client";
41
+ import { pathField, toPath } from "./procedures.js";
42
+ import { checkStoredProgram, type ProgramStore, type ProgramWriteOptions, type StoredProgram } from "./types.js";
43
+
44
+ /** The options of `createProgramProcedures`. */
45
+ export interface ProgramProceduresOptions {
46
+ /**
47
+ * The resolver of the partition of a call: the tenant comes from the principal, not from the
48
+ * input alone. It gives the partition, `undefined` for the store without a partition, or `null`
49
+ * to refuse the call.
50
+ */
51
+ readonly partition: PartitionResolver;
52
+ /**
53
+ * The store of a partition. Give the same store for the same partition each time, and a store of
54
+ * its own for each tenant.
55
+ */
56
+ readonly storeFor: (partition: string | undefined) => ProgramStore | Promise<ProgramStore>;
57
+ /**
58
+ * The namespace of the procedures.
59
+ * @default ["programs"]
60
+ */
61
+ readonly namespace?: ProcedurePath | undefined;
62
+ }
63
+
64
+ /** The program of `programs.save`: a procedure ref, as `procedure.define` takes it. */
65
+ export interface ProgramSaveDefinition {
66
+ [field: string]: unknown;
67
+ $proc: ProcedurePath;
68
+ input?: unknown;
69
+ $name?: string | undefined;
70
+ }
71
+
72
+ /** The input of `programs.save`. */
73
+ export interface ProgramSaveInput {
74
+ path: ProcedurePath | string;
75
+ program: ProgramSaveDefinition;
76
+ name?: string | undefined;
77
+ description?: string | undefined;
78
+ expectedVersion?: string | number | null | undefined;
79
+ partition?: string | undefined;
80
+ }
81
+
82
+ /** The input of `programs.get`: a path or a key. */
83
+ export interface ProgramGetInput {
84
+ path?: ProcedurePath | string | undefined;
85
+ key?: string | undefined;
86
+ partition?: string | undefined;
87
+ }
88
+
89
+ /** The input of `programs.list`. */
90
+ export interface ProgramListInput {
91
+ prefix?: ProcedurePath | string | undefined;
92
+ partition?: string | undefined;
93
+ }
94
+
95
+ /** The input of `programs.remove`. */
96
+ export interface ProgramRemoveInput {
97
+ path: ProcedurePath | string;
98
+ expectedVersion?: string | number | null | undefined;
99
+ partition?: string | undefined;
100
+ }
101
+
102
+ /** The output of `programs.remove`. */
103
+ export interface ProgramRemoveOutput {
104
+ /** True when the store had the program. */
105
+ removed: boolean;
106
+ /** The key of the program. */
107
+ key: string;
108
+ }
109
+
110
+ /** The resource of the effects of the procedures. */
111
+ const PROGRAMS = "programs";
112
+
113
+ const partitionField = z
114
+ .string()
115
+ .regex(PARTITION_PATTERN)
116
+ .optional()
117
+ .describe("The partition that the call asks for, for example a scope. The resolver of the host decides.");
118
+
119
+ const expectedVersionField = z
120
+ .union([z.string().max(1024), z.number(), z.null()])
121
+ .optional()
122
+ .describe("The version that the write expects for the current program. null expects no program. Absent: the last write wins.");
123
+
124
+ /** The schema of a program, as `procedure.define` takes it. */
125
+ const programField = z
126
+ .looseObject({
127
+ $proc: z.array(z.string().min(1)).min(1).describe("The path of the procedure that the program calls"),
128
+ input: z.unknown().optional().describe("The input of that call"),
129
+ $name: z.string().optional().describe("A name that a $ref can read"),
130
+ })
131
+ .describe('The program: a procedure ref, for example { "$proc": ["client", "chain"], "input": { "steps": [...] } }');
132
+
133
+ /** The options of a write: only a given `expectedVersion`, so a store without versions gets no field. */
134
+ function writeOptionsOf(expectedVersion: string | number | null | undefined): ProgramWriteOptions | undefined {
135
+ return expectedVersion === undefined ? undefined : { expectedVersion };
136
+ }
137
+
138
+ /**
139
+ * This function gives the procedures of the saved programs of a tenant: `save`, `get`, `list` and
140
+ * `remove` under the namespace. Each call resolves its partition, and reads and writes only the
141
+ * store of that partition. The function registers nothing.
142
+ *
143
+ * @example
144
+ * ```typescript
145
+ * const stores = new Map<string, ProgramStore>();
146
+ * const procedures = createProgramProcedures({
147
+ * // The tenant comes from the principal. A call without a principal is refused.
148
+ * partition: (ctx) => ctx.principal?.id ?? null,
149
+ * storeFor: (tenant) => {
150
+ * const key = tenant ?? "";
151
+ * if (!stores.has(key)) stores.set(key, new MemoryProgramStore());
152
+ * return stores.get(key)!;
153
+ * },
154
+ * });
155
+ * ```
156
+ */
157
+ export function createProgramProcedures(options: ProgramProceduresOptions): AnyProcedure[] {
158
+ const namespace: ProcedurePath = [...(options.namespace ?? [PROGRAMS])];
159
+ if (namespace.length === 0 || !namespace.every((segment) => typeof segment === "string" && segment !== "")) {
160
+ throw new TypeError("The namespace of the program procedures must be a list of non-empty strings");
161
+ }
162
+ const what = pathToKey(namespace);
163
+
164
+ /** The store of a call: the resolver gives the partition, and `storeFor` its store. */
165
+ const storeOf = async (ctx: ProcedureContext, requested: string | undefined): Promise<ProgramStore> => {
166
+ const partition = await options.partition(ctx, requested);
167
+ if (partition === null) {
168
+ const of = requested === undefined ? "" : ` "${requested}"`;
169
+ throw new InvocationError("FORBIDDEN", `The call cannot use the partition${of} of ${what}`, ctx.path, "partition-not-allowed");
170
+ }
171
+ if (partition !== undefined && !PARTITION_PATTERN.test(partition)) {
172
+ throw new Error(`The partition resolver gave a partition that is not valid: ${JSON.stringify(partition).slice(0, 80)}`);
173
+ }
174
+ return options.storeFor(partition);
175
+ };
176
+
177
+ const notFound = (ctx: ProcedureContext, key: string): InvocationError =>
178
+ new InvocationError("NOT_FOUND", `No program is saved at ${key}`, ctx.path);
179
+
180
+ const save: AnyProcedure = createProcedure()
181
+ .path([...namespace, "save"])
182
+ .input(
183
+ z.object({
184
+ path: pathField,
185
+ program: programField,
186
+ name: z.string().min(1).max(200).optional().describe("The name of the program, for a list of programs"),
187
+ description: z.string().max(2000).optional().describe("The description of the program, in words"),
188
+ expectedVersion: expectedVersionField,
189
+ partition: partitionField,
190
+ })
191
+ )
192
+ .output(typed<StoredProgram>())
193
+ .meta({
194
+ description: "Save a program of the caller's partition: a new program, or a new version of a saved one",
195
+ // The program is data: exec() does not run its refs
196
+ tags: ["programs", "storage", RAW_INPUT_TAG],
197
+ // It replaces the saved program of the same path
198
+ effects: { reads: [PROGRAMS], writes: [PROGRAMS] },
199
+ args: ["path"],
200
+ })
201
+ .handler(async (input: ProgramSaveInput, ctx: ProcedureContext): Promise<StoredProgram> => {
202
+ const path = toPath(input.path);
203
+ if (!isAnyProcedureRef(input.program)) {
204
+ throw new InvocationError("VALIDATION_ERROR", 'The program must be a procedure ref: { "$proc": [...], "input": {...} }', ctx.path);
205
+ }
206
+ const record = checkStoredProgram({
207
+ key: pathToKey(path),
208
+ path: [...path],
209
+ program: input.program as AggregationDefinition,
210
+ storedAt: Date.now(),
211
+ ...(input.name !== undefined && { name: input.name }),
212
+ ...(input.description !== undefined && { description: input.description }),
213
+ });
214
+ const store = await storeOf(ctx, input.partition);
215
+ await store.put(record, writeOptionsOf(input.expectedVersion));
216
+ // The store sets the version: the record of the store is the result
217
+ return (await store.get(record.key)) ?? record;
218
+ })
219
+ .build();
220
+
221
+ const get: AnyProcedure = createProcedure()
222
+ .path([...namespace, "get"])
223
+ .input(
224
+ z.object({
225
+ path: pathField.optional(),
226
+ key: z.string().min(1).optional().describe('The dotted key of the program, for example "reports.weekly"'),
227
+ partition: partitionField,
228
+ })
229
+ )
230
+ .output(typed<StoredProgram>())
231
+ .meta({
232
+ description: "Read a saved program of the caller's partition, with its version",
233
+ tags: ["programs", "storage"],
234
+ effects: { reads: [PROGRAMS], writes: [], idempotent: true },
235
+ })
236
+ .handler(async (input: ProgramGetInput, ctx: ProcedureContext): Promise<StoredProgram> => {
237
+ if ((input.path === undefined) === (input.key === undefined)) {
238
+ throw new InvocationError("VALIDATION_ERROR", `${what}.get needs exactly one of \`path\` or \`key\``, ctx.path);
239
+ }
240
+ const key = input.key ?? pathToKey(toPath(input.path!));
241
+ const store = await storeOf(ctx, input.partition);
242
+ const program = await store.get(key);
243
+ if (!program) throw notFound(ctx, key);
244
+ return program;
245
+ })
246
+ .build();
247
+
248
+ const list: AnyProcedure = createProcedure()
249
+ .path([...namespace, "list"])
250
+ .input(
251
+ z.object({
252
+ prefix: pathField.optional().describe("Only the programs of this path and under it"),
253
+ partition: partitionField,
254
+ })
255
+ )
256
+ .output(typed<StoredProgram[]>())
257
+ .meta({
258
+ description: "List the saved programs of the caller's partition, sorted by key",
259
+ tags: ["programs", "storage"],
260
+ effects: { reads: [PROGRAMS], writes: [], idempotent: true },
261
+ })
262
+ .handler(async (input: ProgramListInput, ctx: ProcedureContext): Promise<StoredProgram[]> => {
263
+ const store = await storeOf(ctx, input.partition);
264
+ return store.list(input.prefix === undefined ? undefined : pathToKey(toPath(input.prefix)));
265
+ })
266
+ .build();
267
+
268
+ const remove: AnyProcedure = createProcedure()
269
+ .path([...namespace, "remove"])
270
+ .input(
271
+ z.object({
272
+ path: pathField,
273
+ expectedVersion: expectedVersionField,
274
+ partition: partitionField,
275
+ })
276
+ )
277
+ .output(
278
+ z.object({
279
+ removed: z.boolean().describe("True when the store had the program"),
280
+ key: z.string().describe("The key of the program"),
281
+ })
282
+ )
283
+ .meta({
284
+ description: "Remove a saved program of the caller's partition",
285
+ tags: ["programs", "storage"],
286
+ effects: { reads: [PROGRAMS], writes: [PROGRAMS], destructive: true, idempotent: true },
287
+ args: ["path"],
288
+ })
289
+ .handler(async (input: ProgramRemoveInput, ctx: ProcedureContext): Promise<ProgramRemoveOutput> => {
290
+ const key = pathToKey(toPath(input.path));
291
+ const store = await storeOf(ctx, input.partition);
292
+ return { removed: await store.delete(key, writeOptionsOf(input.expectedVersion)), key };
293
+ })
294
+ .build();
295
+
296
+ return [save, get, list, remove];
297
+ }
298
+
299
+ /**
300
+ * This function gives the procedures of `createProgramProcedures` as a module, for
301
+ * `createRegistry()` or `registerModules()`. The name of the module has the namespace, so two
302
+ * namespaces give two modules. The function registers nothing.
303
+ */
304
+ export function createProgramModule(options: ProgramProceduresOptions): ProcedureModule {
305
+ const namespace = options.namespace ?? [PROGRAMS];
306
+ return defineModule(`@procedures/store/programs:${pathToKey(namespace)}`, createProgramProcedures(options));
307
+ }