@stigmer/server 3.25.0 → 3.27.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 (254) hide show
  1. package/dist/authorization/evaluator.d.ts +4 -1
  2. package/dist/authorization/evaluator.d.ts.map +1 -1
  3. package/dist/authorization/evaluator.js +8 -0
  4. package/dist/authorization/evaluator.js.map +1 -1
  5. package/dist/authorization/model/agent.d.ts.map +1 -1
  6. package/dist/authorization/model/agent.js +4 -2
  7. package/dist/authorization/model/agent.js.map +1 -1
  8. package/dist/authorization/model/agent_channel.d.ts.map +1 -1
  9. package/dist/authorization/model/agent_channel.js +5 -4
  10. package/dist/authorization/model/agent_channel.js.map +1 -1
  11. package/dist/authorization/model/agent_instance.d.ts.map +1 -1
  12. package/dist/authorization/model/agent_instance.js +3 -2
  13. package/dist/authorization/model/agent_instance.js.map +1 -1
  14. package/dist/authorization/model/mcp_server.d.ts.map +1 -1
  15. package/dist/authorization/model/mcp_server.js +3 -2
  16. package/dist/authorization/model/mcp_server.js.map +1 -1
  17. package/dist/authorization/model/organization.d.ts.map +1 -1
  18. package/dist/authorization/model/organization.js +3 -0
  19. package/dist/authorization/model/organization.js.map +1 -1
  20. package/dist/authorization/model/plugin.d.ts.map +1 -1
  21. package/dist/authorization/model/plugin.js +4 -2
  22. package/dist/authorization/model/plugin.js.map +1 -1
  23. package/dist/authorization/model/rewrite.d.ts +25 -7
  24. package/dist/authorization/model/rewrite.d.ts.map +1 -1
  25. package/dist/authorization/model/rewrite.js +40 -1
  26. package/dist/authorization/model/rewrite.js.map +1 -1
  27. package/dist/authorization/model/schedule.d.ts.map +1 -1
  28. package/dist/authorization/model/schedule.js +8 -3
  29. package/dist/authorization/model/schedule.js.map +1 -1
  30. package/dist/authorization/model/skill.d.ts.map +1 -1
  31. package/dist/authorization/model/skill.js +3 -2
  32. package/dist/authorization/model/skill.js.map +1 -1
  33. package/dist/authorization/model/workflow.d.ts.map +1 -1
  34. package/dist/authorization/model/workflow.js +3 -2
  35. package/dist/authorization/model/workflow.js.map +1 -1
  36. package/dist/authorization/model/workflow_execution.d.ts.map +1 -1
  37. package/dist/authorization/model/workflow_execution.js +4 -3
  38. package/dist/authorization/model/workflow_execution.js.map +1 -1
  39. package/dist/authorization/model/workflow_instance.d.ts.map +1 -1
  40. package/dist/authorization/model/workflow_instance.js +5 -1
  41. package/dist/authorization/model/workflow_instance.js.map +1 -1
  42. package/dist/boot/compose.d.ts.map +1 -1
  43. package/dist/boot/compose.js +13 -8
  44. package/dist/boot/compose.js.map +1 -1
  45. package/dist/boot/list-indexes.d.ts +3 -0
  46. package/dist/boot/list-indexes.d.ts.map +1 -0
  47. package/dist/boot/list-indexes.js +24 -0
  48. package/dist/boot/list-indexes.js.map +1 -0
  49. package/dist/domain/agentexecution/controller.d.ts.map +1 -1
  50. package/dist/domain/agentexecution/controller.js +6 -8
  51. package/dist/domain/agentexecution/controller.js.map +1 -1
  52. package/dist/domain/agentexecution/list-index.d.ts +2 -0
  53. package/dist/domain/agentexecution/list-index.d.ts.map +1 -0
  54. package/dist/domain/agentexecution/list-index.js +23 -0
  55. package/dist/domain/agentexecution/list-index.js.map +1 -0
  56. package/dist/domain/agentexecution/steps.d.ts +17 -33
  57. package/dist/domain/agentexecution/steps.d.ts.map +1 -1
  58. package/dist/domain/agentexecution/steps.js +94 -95
  59. package/dist/domain/agentexecution/steps.js.map +1 -1
  60. package/dist/domain/agentexecution/usage.d.ts.map +1 -1
  61. package/dist/domain/agentexecution/usage.js +37 -14
  62. package/dist/domain/agentexecution/usage.js.map +1 -1
  63. package/dist/domain/artifact/controller.d.ts.map +1 -1
  64. package/dist/domain/artifact/controller.js +27 -15
  65. package/dist/domain/artifact/controller.js.map +1 -1
  66. package/dist/domain/artifact/list-index.d.ts +2 -0
  67. package/dist/domain/artifact/list-index.d.ts.map +1 -0
  68. package/dist/domain/artifact/list-index.js +21 -0
  69. package/dist/domain/artifact/list-index.js.map +1 -0
  70. package/dist/domain/iampolicy/access-lists.d.ts +5 -3
  71. package/dist/domain/iampolicy/access-lists.d.ts.map +1 -1
  72. package/dist/domain/iampolicy/access-lists.js +72 -19
  73. package/dist/domain/iampolicy/access-lists.js.map +1 -1
  74. package/dist/domain/iampolicy/constants.d.ts +32 -6
  75. package/dist/domain/iampolicy/constants.d.ts.map +1 -1
  76. package/dist/domain/iampolicy/constants.js +41 -6
  77. package/dist/domain/iampolicy/constants.js.map +1 -1
  78. package/dist/domain/iampolicy/controller.d.ts +6 -0
  79. package/dist/domain/iampolicy/controller.d.ts.map +1 -1
  80. package/dist/domain/iampolicy/controller.js +16 -7
  81. package/dist/domain/iampolicy/controller.js.map +1 -1
  82. package/dist/domain/iampolicy/resource-store.d.ts.map +1 -1
  83. package/dist/domain/iampolicy/resource-store.js +2 -6
  84. package/dist/domain/iampolicy/resource-store.js.map +1 -1
  85. package/dist/domain/iampolicy/steps.d.ts.map +1 -1
  86. package/dist/domain/iampolicy/steps.js +49 -7
  87. package/dist/domain/iampolicy/steps.js.map +1 -1
  88. package/dist/domain/session/list-index.d.ts +4 -0
  89. package/dist/domain/session/list-index.d.ts.map +1 -0
  90. package/dist/domain/session/list-index.js +26 -0
  91. package/dist/domain/session/list-index.js.map +1 -0
  92. package/dist/domain/session/steps.d.ts +7 -8
  93. package/dist/domain/session/steps.d.ts.map +1 -1
  94. package/dist/domain/session/steps.js +76 -78
  95. package/dist/domain/session/steps.js.map +1 -1
  96. package/dist/domain/workflow/registry/data/task-kind-registry.json +1 -0
  97. package/dist/domain/workflowexecution/controller.d.ts.map +1 -1
  98. package/dist/domain/workflowexecution/controller.js +53 -55
  99. package/dist/domain/workflowexecution/controller.js.map +1 -1
  100. package/dist/domain/workflowexecution/execution-filter.d.ts +4 -0
  101. package/dist/domain/workflowexecution/execution-filter.d.ts.map +1 -1
  102. package/dist/domain/workflowexecution/execution-filter.js +11 -5
  103. package/dist/domain/workflowexecution/execution-filter.js.map +1 -1
  104. package/dist/domain/workflowexecution/get-execution-summary.d.ts.map +1 -1
  105. package/dist/domain/workflowexecution/get-execution-summary.js +23 -17
  106. package/dist/domain/workflowexecution/get-execution-summary.js.map +1 -1
  107. package/dist/domain/workflowexecution/list-index.d.ts +2 -0
  108. package/dist/domain/workflowexecution/list-index.d.ts.map +1 -0
  109. package/dist/domain/workflowexecution/list-index.js +21 -0
  110. package/dist/domain/workflowexecution/list-index.js.map +1 -0
  111. package/dist/domain/workflowexecution/list-pending-approvals.d.ts.map +1 -1
  112. package/dist/domain/workflowexecution/list-pending-approvals.js +69 -32
  113. package/dist/domain/workflowexecution/list-pending-approvals.js.map +1 -1
  114. package/dist/domain/workflowexecution/queries.d.ts +30 -4
  115. package/dist/domain/workflowexecution/queries.d.ts.map +1 -1
  116. package/dist/domain/workflowexecution/queries.js +76 -22
  117. package/dist/domain/workflowexecution/queries.js.map +1 -1
  118. package/dist/extensions/drivers.d.ts +12 -0
  119. package/dist/extensions/drivers.d.ts.map +1 -1
  120. package/dist/extensions/gate-slots.d.ts +10 -1
  121. package/dist/extensions/gate-slots.d.ts.map +1 -1
  122. package/dist/extensions/gate-slots.js +1 -0
  123. package/dist/extensions/gate-slots.js.map +1 -1
  124. package/dist/extensions/list-read-scope.d.ts +9 -12
  125. package/dist/extensions/list-read-scope.d.ts.map +1 -1
  126. package/dist/extensions/list-read-scope.js.map +1 -1
  127. package/dist/extensions/principal-display.d.ts +34 -0
  128. package/dist/extensions/principal-display.d.ts.map +1 -0
  129. package/dist/extensions/principal-display.js +2 -0
  130. package/dist/extensions/principal-display.js.map +1 -0
  131. package/dist/extensions/registry.d.ts +6 -0
  132. package/dist/extensions/registry.d.ts.map +1 -1
  133. package/dist/extensions/registry.js +10 -0
  134. package/dist/extensions/registry.js.map +1 -1
  135. package/dist/index.d.ts +4 -2
  136. package/dist/index.d.ts.map +1 -1
  137. package/dist/index.js +6 -1
  138. package/dist/index.js.map +1 -1
  139. package/dist/pipeline/apiresource-meta.d.ts +10 -0
  140. package/dist/pipeline/apiresource-meta.d.ts.map +1 -1
  141. package/dist/pipeline/apiresource-meta.js +16 -0
  142. package/dist/pipeline/apiresource-meta.js.map +1 -1
  143. package/dist/pipeline/steps/list-page.d.ts +63 -0
  144. package/dist/pipeline/steps/list-page.d.ts.map +1 -0
  145. package/dist/pipeline/steps/list-page.js +187 -0
  146. package/dist/pipeline/steps/list-page.js.map +1 -0
  147. package/dist/query/activity/handler.d.ts +6 -7
  148. package/dist/query/activity/handler.d.ts.map +1 -1
  149. package/dist/query/activity/handler.js +42 -59
  150. package/dist/query/activity/handler.js.map +1 -1
  151. package/dist/store/interface.d.ts +29 -0
  152. package/dist/store/interface.d.ts.map +1 -1
  153. package/dist/store/interface.js.map +1 -1
  154. package/dist/store/list-index.d.ts +123 -0
  155. package/dist/store/list-index.d.ts.map +1 -0
  156. package/dist/store/list-index.js +277 -0
  157. package/dist/store/list-index.js.map +1 -0
  158. package/dist/store/logger.d.ts +3 -0
  159. package/dist/store/logger.d.ts.map +1 -1
  160. package/dist/store/logger.js +5 -1
  161. package/dist/store/logger.js.map +1 -1
  162. package/dist/store/postgres/migrations.d.ts +10 -6
  163. package/dist/store/postgres/migrations.d.ts.map +1 -1
  164. package/dist/store/postgres/migrations.js +64 -6
  165. package/dist/store/postgres/migrations.js.map +1 -1
  166. package/dist/store/postgres/store.d.ts +26 -5
  167. package/dist/store/postgres/store.d.ts.map +1 -1
  168. package/dist/store/postgres/store.js +377 -22
  169. package/dist/store/postgres/store.js.map +1 -1
  170. package/dist/store/sqlite/migrations.d.ts +11 -5
  171. package/dist/store/sqlite/migrations.d.ts.map +1 -1
  172. package/dist/store/sqlite/migrations.js +50 -4
  173. package/dist/store/sqlite/migrations.js.map +1 -1
  174. package/dist/store/sqlite/store.d.ts +18 -3
  175. package/dist/store/sqlite/store.d.ts.map +1 -1
  176. package/dist/store/sqlite/store.js +321 -15
  177. package/dist/store/sqlite/store.js.map +1 -1
  178. package/package.json +6 -6
  179. package/src/authorization/__tests__/drivers.ts +6 -1
  180. package/src/authorization/__tests__/evaluator.test.ts +127 -2
  181. package/src/authorization/evaluator.ts +12 -1
  182. package/src/authorization/model/__tests__/registry.test.ts +64 -2
  183. package/src/authorization/model/agent.ts +4 -1
  184. package/src/authorization/model/agent_channel.ts +11 -3
  185. package/src/authorization/model/agent_instance.ts +3 -1
  186. package/src/authorization/model/mcp_server.ts +3 -1
  187. package/src/authorization/model/organization.ts +3 -0
  188. package/src/authorization/model/plugin.ts +4 -1
  189. package/src/authorization/model/rewrite.ts +65 -8
  190. package/src/authorization/model/schedule.ts +11 -2
  191. package/src/authorization/model/skill.ts +3 -1
  192. package/src/authorization/model/workflow.ts +3 -1
  193. package/src/authorization/model/workflow_execution.ts +4 -2
  194. package/src/authorization/model/workflow_instance.ts +5 -0
  195. package/src/boot/__tests__/list-indexes.test.ts +65 -0
  196. package/src/boot/compose.ts +25 -13
  197. package/src/boot/list-indexes.ts +25 -0
  198. package/src/domain/agentexecution/__tests__/agentexecution.test.ts +85 -3
  199. package/src/domain/agentexecution/controller.ts +8 -10
  200. package/src/domain/agentexecution/list-index.ts +24 -0
  201. package/src/domain/agentexecution/steps.ts +118 -115
  202. package/src/domain/agentexecution/usage.ts +38 -17
  203. package/src/domain/artifact/controller.ts +28 -18
  204. package/src/domain/artifact/list-index.ts +22 -0
  205. package/src/domain/iampolicy/__tests__/access-lists.test.ts +120 -6
  206. package/src/domain/iampolicy/__tests__/constants.test.ts +26 -4
  207. package/src/domain/iampolicy/__tests__/controller.test.ts +55 -1
  208. package/src/domain/iampolicy/__tests__/grant-path.test.ts +3 -3
  209. package/src/domain/iampolicy/__tests__/steps.test.ts +123 -1
  210. package/src/domain/iampolicy/access-lists.ts +98 -23
  211. package/src/domain/iampolicy/constants.ts +50 -6
  212. package/src/domain/iampolicy/controller.ts +30 -7
  213. package/src/domain/iampolicy/resource-store.ts +3 -6
  214. package/src/domain/iampolicy/steps.ts +89 -7
  215. package/src/domain/session/list-index.ts +28 -0
  216. package/src/domain/session/steps.ts +108 -123
  217. package/src/domain/workflow/registry/data/task-kind-registry.json +1 -0
  218. package/src/domain/workflowexecution/__tests__/list-read-scope-summaries.test.ts +155 -99
  219. package/src/domain/workflowexecution/__tests__/workflowexecution.test.ts +193 -31
  220. package/src/domain/workflowexecution/controller.ts +54 -82
  221. package/src/domain/workflowexecution/execution-filter.ts +20 -7
  222. package/src/domain/workflowexecution/get-execution-summary.ts +31 -24
  223. package/src/domain/workflowexecution/list-index.ts +22 -0
  224. package/src/domain/workflowexecution/list-pending-approvals.ts +82 -25
  225. package/src/domain/workflowexecution/queries.ts +134 -23
  226. package/src/extensions/__tests__/iam-policy-points.test.ts +30 -0
  227. package/src/extensions/__tests__/registry.test.ts +3 -0
  228. package/src/extensions/drivers.ts +12 -0
  229. package/src/extensions/gate-slots.ts +10 -0
  230. package/src/extensions/list-read-scope.ts +9 -12
  231. package/src/extensions/principal-display.ts +37 -0
  232. package/src/extensions/registry.ts +19 -0
  233. package/src/index.ts +19 -1
  234. package/src/pipeline/apiresource-meta.ts +21 -0
  235. package/src/pipeline/steps/__tests__/list-page.test.ts +186 -0
  236. package/src/pipeline/steps/list-page.ts +284 -0
  237. package/src/query/activity/handler.ts +57 -78
  238. package/src/store/README.md +51 -32
  239. package/src/store/__tests__/list-index.measure.test.ts +293 -0
  240. package/src/store/__tests__/list-index.test.ts +328 -0
  241. package/src/store/__tests__/store-contract.ts +346 -3
  242. package/src/store/interface.ts +39 -0
  243. package/src/store/list-index.ts +465 -0
  244. package/src/store/logger.ts +8 -1
  245. package/src/store/postgres/__tests__/list-index-repair.test.ts +133 -0
  246. package/src/store/postgres/__tests__/migrations.test.ts +27 -19
  247. package/src/store/postgres/__tests__/store-contract.test.ts +50 -3
  248. package/src/store/postgres/migrations.ts +69 -7
  249. package/src/store/postgres/store.ts +485 -29
  250. package/src/store/sqlite/__tests__/migrations.test.ts +51 -7
  251. package/src/store/sqlite/__tests__/store-contract.test.ts +70 -16
  252. package/src/store/sqlite/__tests__/support.ts +12 -3
  253. package/src/store/sqlite/migrations.ts +57 -6
  254. package/src/store/sqlite/store.ts +462 -21
@@ -0,0 +1,465 @@
1
+ /**
2
+ * The list index: what a store keeps beside a row so a list lane can read
3
+ * one organization's or one parent's rows in creation order without
4
+ * decoding the whole kind. Every list lane used to call `listResources`
5
+ * and filter in memory, and the payload is opaque bytes by contract
6
+ * (postgres/migrations.ts: audit hashes are content-addressed over them),
7
+ * so an index is projected columns written beside the blob, never a path
8
+ * into it.
9
+ *
10
+ * A kind is list-indexed exactly when its declaration appears in the one
11
+ * explicit list the composition root hands to the store when it opens
12
+ * (boot/list-indexes.ts) — the search registry's idiom
13
+ * (query/search/registry.ts). A declaration lives beside the lanes that
14
+ * filter on its keys (`domain/<kind>/list-index.ts`) and states them as
15
+ * data, a field path or a label, so the store derives them without
16
+ * knowing any domain: this module is the one derivation every writer, the
17
+ * reconciliation at open and every read of an unproven row share.
18
+ *
19
+ * Facts are written atomically with the row, never best-effort (the one
20
+ * deliberate divergence from search, whose index only degrades search: a
21
+ * missing list row hides a row). A row's facts are PROVEN current when
22
+ * its stamp equals its `updated_at` and its revision is its declaration's;
23
+ * a row written by a binary that does not know the index (every production
24
+ * roll overlaps the old and the new pod for a minute), or by one with
25
+ * another revision of the declaration, is UNPROVEN, and a read evaluates
26
+ * it from its bytes with this module and repairs it (`queryResources` in
27
+ * interface.ts states the contract). Exactness is therefore the store's
28
+ * guarantee, independent of timing.
29
+ *
30
+ * The order is one for every indexed read: newest first on the audit's
31
+ * `spec_audit.created_at`, ties broken by id, both compared as bytes. The
32
+ * instant is fixed-width UTC text so the drivers sort it as text and this
33
+ * module compares it the same way; an absent stamp is the empty string,
34
+ * which sorts last newest-first — exactly `compareCreatedAtDesc`
35
+ * (pipeline/steps/helpers.ts), the order the session lists always had.
36
+ *
37
+ * Proven by __tests__/list-index.test.ts (declaration validation, the
38
+ * derivation, the instant, the merge) and, per driver, by the list-index
39
+ * arms of __tests__/store-contract.ts.
40
+ */
41
+ import { ScalarType } from "@bufbuild/protobuf";
42
+ import type { DescField, DescMessage } from "@bufbuild/protobuf";
43
+
44
+ import type { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
45
+
46
+ import { apiResourceKindName } from "./proto-fields.js";
47
+
48
+ // =============================================================================
49
+ // Declarations
50
+ // =============================================================================
51
+
52
+ /** Where a list key's value is read from: a string field or a label. */
53
+ export type ListKeySource =
54
+ | { readonly from: "field"; readonly path: string }
55
+ | { readonly from: "label"; readonly label: string };
56
+
57
+ /** A string field of the resource, by proto field names from the root ("spec.session_id"). */
58
+ export function field(path: string): ListKeySource {
59
+ return { from: "field", path };
60
+ }
61
+
62
+ /** A `metadata.labels` entry. */
63
+ export function label(key: string): ListKeySource {
64
+ return { from: "label", label: key };
65
+ }
66
+
67
+ /**
68
+ * One kind's list index. `revision` is bumped whenever `keys` changes, so
69
+ * rows written under another revision are re-derived rather than read
70
+ * through keys they may lack; boot/__tests__/list-indexes.test.ts pins
71
+ * every declaration's keys to its revision so a change cannot forget it.
72
+ */
73
+ export interface ListIndexDeclaration<K extends string = string> {
74
+ readonly kind: ApiResourceKind;
75
+ readonly schema: DescMessage;
76
+ readonly revision: number;
77
+ readonly keys: Readonly<Record<K, ListKeySource>>;
78
+ }
79
+
80
+ interface ResolvedKey {
81
+ readonly name: string;
82
+ readonly read: (resource: object) => string;
83
+ }
84
+
85
+ const resolvedKeys = new WeakMap<
86
+ ListIndexDeclaration,
87
+ ReadonlyArray<ResolvedKey>
88
+ >();
89
+
90
+ /**
91
+ * Declares a kind's list index, resolving every key against the schema so
92
+ * a misspelt path or a non-string field fails at module load, not as a
93
+ * silently empty list.
94
+ */
95
+ export function declareListIndex<K extends string>(
96
+ declaration: ListIndexDeclaration<K>,
97
+ ): ListIndexDeclaration<K> {
98
+ if (!Number.isInteger(declaration.revision) || declaration.revision < 1) {
99
+ throw new Error(
100
+ `list index for ${apiResourceKindName(declaration.kind)}: revision must be a positive integer`,
101
+ );
102
+ }
103
+ const keys: ResolvedKey[] = [];
104
+ for (const [name, source] of Object.entries<ListKeySource>(
105
+ declaration.keys,
106
+ )) {
107
+ keys.push({ name, read: resolveKeyReader(declaration, name, source) });
108
+ }
109
+ const frozen = Object.freeze({
110
+ ...declaration,
111
+ keys: Object.freeze({ ...declaration.keys }),
112
+ });
113
+ resolvedKeys.set(frozen, keys);
114
+ return frozen;
115
+ }
116
+
117
+ function resolveKeyReader(
118
+ declaration: ListIndexDeclaration,
119
+ name: string,
120
+ source: ListKeySource,
121
+ ): (resource: object) => string {
122
+ switch (source.from) {
123
+ case "label":
124
+ return (resource) =>
125
+ (resource as ResourceShape).metadata?.labels?.[source.label] ?? "";
126
+ case "field": {
127
+ const localNames = resolveFieldPath(declaration, name, source.path);
128
+ return (resource) => {
129
+ let value: unknown = resource;
130
+ for (const localName of localNames) {
131
+ if (value === null || typeof value !== "object") {
132
+ return "";
133
+ }
134
+ value = (value as Record<string, unknown>)[localName];
135
+ }
136
+ return typeof value === "string" ? value : "";
137
+ };
138
+ }
139
+ default: {
140
+ const exhaustive: never = source;
141
+ throw new Error(`unknown list key source: ${String(exhaustive)}`);
142
+ }
143
+ }
144
+ }
145
+
146
+ function resolveFieldPath(
147
+ declaration: ListIndexDeclaration,
148
+ name: string,
149
+ path: string,
150
+ ): ReadonlyArray<string> {
151
+ const where = `list index for ${apiResourceKindName(declaration.kind)}, key '${name}'`;
152
+ const segments = path.split(".");
153
+ const localNames: string[] = [];
154
+ let message: DescMessage | undefined = declaration.schema;
155
+ segments.forEach((segment, index) => {
156
+ const found: DescField | undefined = message?.fields.find(
157
+ (f) => f.name === segment,
158
+ );
159
+ if (found === undefined) {
160
+ throw new Error(`${where}: '${path}' has no field '${segment}'`);
161
+ }
162
+ localNames.push(found.localName);
163
+ const last = index === segments.length - 1;
164
+ if (last) {
165
+ if (found.fieldKind !== "scalar" || found.scalar !== ScalarType.STRING) {
166
+ throw new Error(`${where}: '${path}' is not a string field`);
167
+ }
168
+ return;
169
+ }
170
+ if (found.fieldKind !== "message") {
171
+ throw new Error(`${where}: '${segment}' in '${path}' is not a message`);
172
+ }
173
+ message = found.message;
174
+ });
175
+ return localNames;
176
+ }
177
+
178
+ /** The declaration's shape as text, for the revision pin. */
179
+ export function listIndexFingerprint(
180
+ declaration: ListIndexDeclaration,
181
+ ): string {
182
+ const keys = Object.entries<ListKeySource>(declaration.keys)
183
+ .map(([name, source]) =>
184
+ source.from === "field"
185
+ ? `${name}=field:${source.path}`
186
+ : `${name}=label:${source.label}`,
187
+ )
188
+ .sort();
189
+ return `${apiResourceKindName(declaration.kind)}{${keys.join(",")}}`;
190
+ }
191
+
192
+ // =============================================================================
193
+ // Facts
194
+ // =============================================================================
195
+
196
+ /** The facts a store keeps beside one row of a list-indexed kind. */
197
+ export interface ListIndexFacts {
198
+ readonly org: string;
199
+ readonly createdAt: string;
200
+ readonly revision: number;
201
+ readonly keys: ReadonlyArray<{
202
+ readonly key: string;
203
+ readonly value: string;
204
+ }>;
205
+ }
206
+
207
+ interface TimestampShape {
208
+ readonly seconds: bigint;
209
+ readonly nanos: number;
210
+ }
211
+
212
+ interface ResourceShape {
213
+ readonly metadata?: {
214
+ readonly id?: string;
215
+ readonly org?: string;
216
+ readonly labels?: Readonly<Record<string, string>>;
217
+ };
218
+ readonly status?: {
219
+ readonly audit?: {
220
+ readonly specAudit?: { readonly createdAt?: TimestampShape };
221
+ };
222
+ };
223
+ }
224
+
225
+ /** Derives a decoded row's facts; every absence is the empty value, never a throw. */
226
+ export function listIndexFactsOf(
227
+ declaration: ListIndexDeclaration,
228
+ resource: object,
229
+ ): ListIndexFacts {
230
+ const shaped = resource as ResourceShape;
231
+ const keys: Array<{ key: string; value: string }> = [];
232
+ for (const resolved of keysOf(declaration)) {
233
+ const value = resolved.read(resource);
234
+ if (value !== "") {
235
+ keys.push({ key: resolved.name, value });
236
+ }
237
+ }
238
+ return {
239
+ org: shaped.metadata?.org ?? "",
240
+ createdAt: listIndexInstant(shaped.status?.audit?.specAudit?.createdAt),
241
+ revision: declaration.revision,
242
+ keys,
243
+ };
244
+ }
245
+
246
+ /**
247
+ * Whether two derivations would write the same key rows — the check that
248
+ * lets an update skip rewriting them, which a status write never needs.
249
+ */
250
+ export function sameListKeyRows(a: ListIndexFacts, b: ListIndexFacts): boolean {
251
+ return (
252
+ a.createdAt === b.createdAt &&
253
+ a.keys.length === b.keys.length &&
254
+ a.keys.every(
255
+ (k, i) => k.key === b.keys[i]?.key && k.value === b.keys[i]?.value,
256
+ )
257
+ );
258
+ }
259
+
260
+ function keysOf(declaration: ListIndexDeclaration): ReadonlyArray<ResolvedKey> {
261
+ const keys = resolvedKeys.get(declaration);
262
+ if (keys === undefined) {
263
+ throw new Error(
264
+ `list index for ${apiResourceKindName(declaration.kind)} was not made by declareListIndex`,
265
+ );
266
+ }
267
+ return keys;
268
+ }
269
+
270
+ // The instant is representable as four-digit-year ISO text in this range;
271
+ // outside it a stamp is treated as absent rather than mis-sorted.
272
+ const MIN_INSTANT_SECONDS = -62_135_596_800n; // 0001-01-01T00:00:00Z
273
+ const MAX_INSTANT_SECONDS = 253_402_300_799n; // 9999-12-31T23:59:59Z
274
+
275
+ /**
276
+ * A timestamp as the index's fixed-width UTC text
277
+ * (`YYYY-MM-DDTHH:MM:SS.nnnnnnnnnZ`), or "" when absent.
278
+ */
279
+ export function listIndexInstant(
280
+ timestamp: TimestampShape | undefined,
281
+ ): string {
282
+ if (
283
+ timestamp === undefined ||
284
+ timestamp.seconds < MIN_INSTANT_SECONDS ||
285
+ timestamp.seconds > MAX_INSTANT_SECONDS
286
+ ) {
287
+ return "";
288
+ }
289
+ const iso = new Date(Number(timestamp.seconds) * 1000).toISOString();
290
+ const nanos = String(
291
+ Math.max(0, Math.min(999_999_999, timestamp.nanos)),
292
+ ).padStart(9, "0");
293
+ return `${iso.slice(0, 19)}.${nanos}Z`;
294
+ }
295
+
296
+ /** Epoch milliseconds as the index's instant, for a `createdAtOrAfter` bound. */
297
+ export function listIndexInstantOfMillis(millis: number): string {
298
+ const seconds = Math.floor(millis / 1000);
299
+ const nanos = (millis - seconds * 1000) * 1_000_000;
300
+ return listIndexInstant({ seconds: BigInt(seconds), nanos });
301
+ }
302
+
303
+ // =============================================================================
304
+ // Queries
305
+ // =============================================================================
306
+
307
+ /** A position in the index's order: the last row a read handed out. */
308
+ export interface ListIndexCursor {
309
+ readonly createdAt: string;
310
+ readonly id: string;
311
+ }
312
+
313
+ /**
314
+ * One indexed read. Every predicate is optional and they AND together;
315
+ * `anyKey` matches a row whose value for ANY listed key equals the given
316
+ * value (a workflow run names its workflow or its instance).
317
+ */
318
+ export interface ListIndexQuery<K extends string = string> {
319
+ /** One organization's rows; "" or absent reads every organization. */
320
+ readonly org?: string;
321
+ readonly anyKey?: ReadonlyArray<{ readonly name: K; readonly value: string }>;
322
+ /** Rows created at or after this instant, plus rows with no creation stamp. */
323
+ readonly createdAtOrAfter?: string;
324
+ /** Rows strictly after this position in the order. */
325
+ readonly after?: ListIndexCursor;
326
+ /** At most this many rows; absent reads every matching row. */
327
+ readonly limit?: number;
328
+ }
329
+
330
+ /** One row a read hands out, with its position for the next read. */
331
+ export interface ListIndexRow {
332
+ readonly id: string;
333
+ readonly data: Uint8Array;
334
+ readonly cursor: ListIndexCursor;
335
+ }
336
+
337
+ /** Byte order, the order both drivers sort text in (Postgres `COLLATE "C"`, sqlite BINARY). */
338
+ function compareBytes(a: string, b: string): number {
339
+ return a < b ? -1 : a > b ? 1 : 0;
340
+ }
341
+
342
+ /** Negative when `a` comes first in the index's newest-first order. */
343
+ export function compareListIndexOrder(
344
+ a: ListIndexCursor,
345
+ b: ListIndexCursor,
346
+ ): number {
347
+ const byCreated = compareBytes(b.createdAt, a.createdAt);
348
+ return byCreated !== 0 ? byCreated : compareBytes(b.id, a.id);
349
+ }
350
+
351
+ /** Whether a row's facts satisfy a query's predicates, the cursor included. */
352
+ export function matchesListIndexQuery(
353
+ id: string,
354
+ facts: ListIndexFacts,
355
+ query: ListIndexQuery,
356
+ ): boolean {
357
+ if (query.org !== undefined && query.org !== "" && facts.org !== query.org) {
358
+ return false;
359
+ }
360
+ if (query.anyKey !== undefined) {
361
+ const wanted = query.anyKey;
362
+ if (
363
+ !facts.keys.some((k) =>
364
+ wanted.some((w) => w.name === k.key && w.value === k.value),
365
+ )
366
+ ) {
367
+ return false;
368
+ }
369
+ }
370
+ if (
371
+ query.createdAtOrAfter !== undefined &&
372
+ facts.createdAt !== "" &&
373
+ compareBytes(facts.createdAt, query.createdAtOrAfter) < 0
374
+ ) {
375
+ return false;
376
+ }
377
+ if (
378
+ query.after !== undefined &&
379
+ compareListIndexOrder({ createdAt: facts.createdAt, id }, query.after) <= 0
380
+ ) {
381
+ return false;
382
+ }
383
+ return true;
384
+ }
385
+
386
+ /**
387
+ * The drivers' last step: the proven rows the index answered, merged with
388
+ * the unproven rows evaluated from their bytes, in order, without
389
+ * duplicates, cut to the limit. Both inputs already satisfy the query.
390
+ */
391
+ export function mergeListIndexRows(
392
+ proven: ReadonlyArray<ListIndexRow>,
393
+ unproven: ReadonlyArray<ListIndexRow>,
394
+ limit: number | undefined,
395
+ ): ListIndexRow[] {
396
+ const byId = new Map<string, ListIndexRow>();
397
+ for (const row of [...proven, ...unproven]) {
398
+ byId.set(row.id, row);
399
+ }
400
+ const merged = [...byId.values()].sort((a, b) =>
401
+ compareListIndexOrder(a.cursor, b.cursor),
402
+ );
403
+ return limit === undefined ? merged : merged.slice(0, limit);
404
+ }
405
+
406
+ /** Refuses a limit a caller cannot mean: zero, negative or fractional. */
407
+ export function assertListIndexLimit(limit: number | undefined): void {
408
+ if (limit !== undefined && (!Number.isInteger(limit) || limit < 1)) {
409
+ throw new Error(
410
+ `list index limit must be a positive integer, got ${limit}`,
411
+ );
412
+ }
413
+ }
414
+
415
+ // =============================================================================
416
+ // The registry a store opens with
417
+ // =============================================================================
418
+
419
+ /**
420
+ * The declarations one store was opened with, keyed by kind. A read names
421
+ * its declaration and the store refuses one it was not opened with, so a
422
+ * lane and the composition root cannot silently disagree about an index.
423
+ */
424
+ export class ListIndexRegistry {
425
+ private readonly byKind = new Map<ApiResourceKind, ListIndexDeclaration>();
426
+
427
+ constructor(declarations: ReadonlyArray<ListIndexDeclaration>) {
428
+ for (const declaration of declarations) {
429
+ keysOf(declaration);
430
+ if (this.byKind.has(declaration.kind)) {
431
+ throw new Error(
432
+ `list index for ${apiResourceKindName(declaration.kind)} is declared twice`,
433
+ );
434
+ }
435
+ this.byKind.set(declaration.kind, declaration);
436
+ }
437
+ }
438
+
439
+ /** The declaration for a kind, or undefined when the kind is not list-indexed. */
440
+ declarationOf(kind: ApiResourceKind): ListIndexDeclaration | undefined {
441
+ return this.byKind.get(kind);
442
+ }
443
+
444
+ /** Every declaration, in registration order. */
445
+ declarations(): ReadonlyArray<ListIndexDeclaration> {
446
+ return [...this.byKind.values()];
447
+ }
448
+
449
+ /** A decoded row's facts when its kind is list-indexed; undefined otherwise. */
450
+ factsOf(kind: ApiResourceKind, resource: object): ListIndexFacts | undefined {
451
+ const declaration = this.byKind.get(kind);
452
+ return declaration === undefined
453
+ ? undefined
454
+ : listIndexFactsOf(declaration, resource);
455
+ }
456
+
457
+ /** Throws unless this exact declaration was registered. */
458
+ require(declaration: ListIndexDeclaration): void {
459
+ if (this.byKind.get(declaration.kind) !== declaration) {
460
+ throw new Error(
461
+ `list index for ${apiResourceKindName(declaration.kind)} is not registered with this store`,
462
+ );
463
+ }
464
+ }
465
+ }
@@ -3,11 +3,18 @@
3
3
  * compatible with boot/logger.ts, declared here so the store layer never
4
4
  * imports upward from boot/. Lived inline in sqlite/store.ts through
5
5
  * Phase 1; promoted when the Postgres driver became the second consumer.
6
+ * `info` carries what an operator reads at boot (the list index's
7
+ * reconciliation count).
6
8
  */
7
9
  export interface StoreLogger {
8
10
  debug(message: string, fields?: Record<string, unknown>): void;
11
+ info(message: string, fields?: Record<string, unknown>): void;
9
12
  warn(message: string, fields?: Record<string, unknown>): void;
10
13
  }
11
14
 
12
15
  /** Default for tests and tools that want a silent store. */
13
- export const NOOP_STORE_LOGGER: StoreLogger = { debug() {}, warn() {} };
16
+ export const NOOP_STORE_LOGGER: StoreLogger = {
17
+ debug() {},
18
+ info() {},
19
+ warn() {},
20
+ };
@@ -0,0 +1,133 @@
1
+ /**
2
+ * The list index's repair never overwrites a newer write (../store.ts,
3
+ * `deriveAndRepair`): a read that finds a row unproven derives its facts
4
+ * from the bytes it read and writes them back only while those bytes are
5
+ * still the stored ones. Pinned against a real interleaving, not assumed
6
+ * from the statement's shape: a second connection rewrites the row and
7
+ * holds its lock uncommitted, the read's repair queues behind that lock
8
+ * (observed in pg_stat_activity, polled with a timeout, never slept for),
9
+ * the rewrite commits, and the repair then matches nothing — the row
10
+ * stays unproven and the next read answers from the newer bytes. sqlite
11
+ * runs the read and its repair on one synchronous connection, so the
12
+ * interleaving this pins cannot arise inside one process there.
13
+ *
14
+ * Gated on TEST_DATABASE_URL (support.ts).
15
+ */
16
+ import { create, toBinary } from "@bufbuild/protobuf";
17
+ import pg from "pg";
18
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
19
+
20
+ import { SessionSchema } from "@stigmer/protos/ai/stigmer/agentic/session/v1/api_pb";
21
+ import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
22
+
23
+ import { declareListIndex } from "../../list-index.js";
24
+ import { PostgresStore } from "../store.js";
25
+ import {
26
+ createTestDatabase,
27
+ testDatabaseAdminUrl,
28
+ type TestDatabase,
29
+ } from "./support.js";
30
+
31
+ const sessions = declareListIndex({
32
+ kind: ApiResourceKind.session,
33
+ schema: SessionSchema,
34
+ revision: 1,
35
+ keys: {},
36
+ });
37
+
38
+ function sessionBytes(org: string): Buffer {
39
+ return Buffer.from(
40
+ toBinary(
41
+ SessionSchema,
42
+ create(SessionSchema, { metadata: { id: "ses_1", org } }),
43
+ ),
44
+ );
45
+ }
46
+
47
+ async function waitForLockWaiter(
48
+ hooks: pg.Pool,
49
+ timeoutMs: number,
50
+ ): Promise<void> {
51
+ const deadline = Date.now() + timeoutMs;
52
+ while (Date.now() < deadline) {
53
+ const result = await hooks.query(
54
+ `SELECT count(*) AS waiting FROM pg_stat_activity
55
+ WHERE datname = current_database() AND wait_event_type = 'Lock'`,
56
+ );
57
+ if (Number((result.rows[0] as { waiting: string }).waiting) > 0) {
58
+ return;
59
+ }
60
+ await new Promise((resolve) => setImmediate(resolve));
61
+ }
62
+ throw new Error("the repair never queued behind the row lock");
63
+ }
64
+
65
+ describe.skipIf(testDatabaseAdminUrl() === undefined)(
66
+ "postgres list-index repair",
67
+ () => {
68
+ let db: TestDatabase;
69
+ let hooks: pg.Pool;
70
+
71
+ beforeAll(async () => {
72
+ db = await createTestDatabase();
73
+ hooks = new pg.Pool({ connectionString: db.databaseUrl, max: 3 });
74
+ });
75
+
76
+ afterAll(async () => {
77
+ await hooks.end();
78
+ await db.drop();
79
+ });
80
+
81
+ it("leaves a row rewritten while its repair waited unproven, and reads the newer bytes next", async () => {
82
+ const store = await PostgresStore.open(db.databaseUrl, undefined, {
83
+ listIndexes: [sessions],
84
+ });
85
+ try {
86
+ // An older binary's write: the row is unproven.
87
+ await hooks.query(
88
+ `INSERT INTO resources (kind, id, data, updated_at) VALUES ('session', 'ses_1', $1, now())`,
89
+ [sessionBytes("old")],
90
+ );
91
+
92
+ const writer = await hooks.connect();
93
+ try {
94
+ await writer.query("BEGIN");
95
+ await writer.query(
96
+ `UPDATE resources SET data = $1, updated_at = now() WHERE kind = 'session' AND id = 'ses_1'`,
97
+ [sessionBytes("newer")],
98
+ );
99
+
100
+ const read = store.queryResources(sessions, { org: "old" });
101
+ await waitForLockWaiter(hooks, 10_000);
102
+ await writer.query("COMMIT");
103
+
104
+ expect(
105
+ (await read).map((row) => row.id),
106
+ "the read answers from the bytes it read",
107
+ ).toEqual(["ses_1"]);
108
+ } finally {
109
+ writer.release();
110
+ }
111
+
112
+ const stamped = await hooks.query(
113
+ `SELECT list_indexed_at IS NOT DISTINCT FROM updated_at AS stamped, list_org
114
+ FROM resources WHERE kind = 'session' AND id = 'ses_1'`,
115
+ );
116
+ expect(stamped.rows[0], "the repair matched nothing").toEqual({
117
+ stamped: false,
118
+ list_org: null,
119
+ });
120
+ expect(
121
+ (await store.queryResources(sessions, { org: "old" })).length,
122
+ ).toBe(0);
123
+ expect(
124
+ (await store.queryResources(sessions, { org: "newer" })).map(
125
+ (row) => row.id,
126
+ ),
127
+ ).toEqual(["ses_1"]);
128
+ } finally {
129
+ await store.close();
130
+ }
131
+ });
132
+ },
133
+ );