@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
@@ -64,8 +64,23 @@ import type {
64
64
  SignalDedupeStatus,
65
65
  SignalDedupeStore,
66
66
  Store,
67
+ StoreOpenOptions,
67
68
  WorkflowExecutionEventRecord,
68
69
  } from "../interface.js";
70
+ import {
71
+ ListIndexRegistry,
72
+ assertListIndexLimit,
73
+ listIndexFactsOf,
74
+ matchesListIndexQuery,
75
+ mergeListIndexRows,
76
+ sameListKeyRows,
77
+ } from "../list-index.js";
78
+ import type {
79
+ ListIndexDeclaration,
80
+ ListIndexFacts,
81
+ ListIndexQuery,
82
+ ListIndexRow,
83
+ } from "../list-index.js";
69
84
  import { NOOP_STORE_LOGGER } from "../logger.js";
70
85
  import type { StoreLogger } from "../logger.js";
71
86
  import {
@@ -85,6 +100,49 @@ import { normalizeTsRankScore, renderTsQueryExpression } from "./tsquery.js";
85
100
  */
86
101
  const TEXT_SEARCH_CONFIG = "english";
87
102
 
103
+ /**
104
+ * The one upsert every resource write goes through: the row, its
105
+ * `updated_at`, and the list index's facts stamped with the SAME `now()`
106
+ * (constant within a transaction), so a later writer that bumps
107
+ * `updated_at` without knowing the index leaves the row unproven. An
108
+ * undeclared kind writes NULL facts and is stamped all the same, which
109
+ * keeps it out of the unproven partial index.
110
+ */
111
+ const UPSERT_RESOURCE_SQL = `
112
+ INSERT INTO resources
113
+ (kind, id, data, updated_at, list_org, list_created_at, list_index_revision, list_indexed_at)
114
+ VALUES ($1, $2, $3, now(), $4, $5, $6, now())
115
+ ON CONFLICT (kind, id) DO UPDATE SET
116
+ data = excluded.data,
117
+ updated_at = excluded.updated_at,
118
+ list_org = excluded.list_org,
119
+ list_created_at = excluded.list_created_at,
120
+ list_index_revision = excluded.list_index_revision,
121
+ list_indexed_at = excluded.list_indexed_at`;
122
+
123
+ /**
124
+ * The rows of kind `$1` whose facts are not proven current under revision
125
+ * `$2` (list-index.ts), each arm one index range that fetches a row only
126
+ * when it matches: the stamp arm repeats the partial index's predicate
127
+ * verbatim (the only form the planner matches it in), the revision arms
128
+ * are three ranges of the revision index. A single OR over the revision
129
+ * is answered, without statistics, by visiting every heap row of the kind
130
+ * (measured), which grows with the kind; these arms are empty in steady
131
+ * state. They overlap (an unstamped row has no revision either), so the
132
+ * caller keeps one row per id.
133
+ */
134
+ const UNPROVEN_ROWS_SQL = `
135
+ SELECT id, data FROM resources WHERE kind = $1 AND list_indexed_at IS DISTINCT FROM updated_at
136
+ UNION ALL
137
+ SELECT id, data FROM resources WHERE kind = $1 AND list_index_revision IS NULL
138
+ UNION ALL
139
+ SELECT id, data FROM resources WHERE kind = $1 AND list_index_revision < $2
140
+ UNION ALL
141
+ SELECT id, data FROM resources WHERE kind = $1 AND list_index_revision > $2`;
142
+
143
+ /** How many unproven rows the reconciliation at open decodes per round trip. */
144
+ const RECONCILE_BATCH = 500;
145
+
88
146
  export class PostgresStore implements Store {
89
147
  readonly bootstrapState: BootstrapStateStore;
90
148
  readonly signalDedupe: SignalDedupeStore;
@@ -93,10 +151,16 @@ export class PostgresStore implements Store {
93
151
 
94
152
  private pool: Pool | undefined;
95
153
  private readonly logger: StoreLogger;
154
+ private readonly listIndexes: ListIndexRegistry;
96
155
 
97
- private constructor(pool: Pool, logger: StoreLogger) {
156
+ private constructor(
157
+ pool: Pool,
158
+ logger: StoreLogger,
159
+ listIndexes: ListIndexRegistry,
160
+ ) {
98
161
  this.pool = pool;
99
162
  this.logger = logger;
163
+ this.listIndexes = listIndexes;
100
164
  this.bootstrapState = new PostgresBootstrapStateStore(() => this.open());
101
165
  this.signalDedupe = new PostgresSignalDedupeStore(
102
166
  () => this.open(),
@@ -110,14 +174,19 @@ export class PostgresStore implements Store {
110
174
 
111
175
  /**
112
176
  * Connects to databaseUrl, runs migrations (advisory-locked, so
113
- * concurrent boots serialize), and returns the ready store. Any failure
114
- * here is a loud boot throw — never a degraded server (the same posture
115
- * as SqliteStore.open; the composition root does not catch it).
177
+ * concurrent boots serialize), reconciles the list index (derives the
178
+ * facts of every unproven row of a declared kind; compare-and-set, so
179
+ * replicas opening together cannot conflict), and returns the ready
180
+ * store. Any failure here is a loud boot throw — never a degraded server
181
+ * (the same posture as SqliteStore.open; the composition root does not
182
+ * catch it).
116
183
  */
117
184
  static async open(
118
185
  databaseUrl: string,
119
186
  logger: StoreLogger = NOOP_STORE_LOGGER,
187
+ options: StoreOpenOptions = {},
120
188
  ): Promise<PostgresStore> {
189
+ const listIndexes = new ListIndexRegistry(options.listIndexes ?? []);
121
190
  const pool = new pg.Pool({ connectionString: databaseUrl });
122
191
  // An idle pooled connection dropping (server restart, network blip)
123
192
  // emits 'error' on the pool; without a listener that is a process
@@ -140,7 +209,14 @@ export class PostgresStore implements Store {
140
209
  }
141
210
  client.release();
142
211
 
143
- return new PostgresStore(pool, logger);
212
+ const store = new PostgresStore(pool, logger, listIndexes);
213
+ try {
214
+ await store.reconcileListIndexes();
215
+ } catch (error) {
216
+ await store.close();
217
+ throw error;
218
+ }
219
+ return store;
144
220
  }
145
221
 
146
222
  // ---------------------------------------------------------------------------
@@ -153,12 +229,31 @@ export class PostgresStore implements Store {
153
229
  schema: Desc,
154
230
  msg: MessageShape<Desc>,
155
231
  ): Promise<void> {
156
- const data = toBinary(schema, msg);
157
- await this.open().query(
158
- `INSERT INTO resources (kind, id, data, updated_at) VALUES ($1, $2, $3, now())
159
- ON CONFLICT (kind, id) DO UPDATE SET data = excluded.data, updated_at = now()`,
160
- [apiResourceKindName(kind), id, Buffer.from(data)],
161
- );
232
+ const data = Buffer.from(toBinary(schema, msg));
233
+ const kindName = apiResourceKindName(kind);
234
+ const facts = this.listIndexes.factsOf(kind, msg);
235
+ if (facts === undefined) {
236
+ await this.open().query(UPSERT_RESOURCE_SQL, [
237
+ kindName,
238
+ id,
239
+ data,
240
+ null,
241
+ null,
242
+ null,
243
+ ]);
244
+ return;
245
+ }
246
+ await this.withTransaction(async (client) => {
247
+ await client.query(UPSERT_RESOURCE_SQL, [
248
+ kindName,
249
+ id,
250
+ data,
251
+ facts.org,
252
+ facts.createdAt,
253
+ facts.revision,
254
+ ]);
255
+ await replaceListKeys(client, kindName, id, facts);
256
+ });
162
257
  }
163
258
 
164
259
  async getResource<Desc extends DescMessage>(
@@ -191,22 +286,49 @@ export class PostgresStore implements Store {
191
286
  // (interface.ts), so no caller I/O can extend the lock's hold time.
192
287
  return this.withTransaction(async (client) => {
193
288
  const result = await client.query(
194
- `SELECT data FROM resources WHERE kind = $1 AND id = $2 FOR UPDATE`,
289
+ `SELECT data, list_indexed_at IS NOT DISTINCT FROM updated_at AS stamped, list_index_revision
290
+ FROM resources WHERE kind = $1 AND id = $2 FOR UPDATE`,
195
291
  [kindName, id],
196
292
  );
197
- const row = result.rows[0] as { data: Uint8Array } | undefined;
293
+ const row = result.rows[0] as
294
+ | {
295
+ data: Uint8Array;
296
+ stamped: boolean;
297
+ list_index_revision: number | null;
298
+ }
299
+ | undefined;
198
300
  if (row === undefined) {
199
301
  throw new ResourceNotFoundError(`${kindName}/${id}`);
200
302
  }
201
303
 
202
304
  const msg = fromBinary(schema, row.data);
305
+ const before = this.listIndexes.factsOf(kind, msg);
203
306
  modify(msg);
307
+ const after = this.listIndexes.factsOf(kind, msg);
204
308
 
205
309
  const data = toBinary(schema, msg);
206
310
  await client.query(
207
- `UPDATE resources SET data = $3, updated_at = now() WHERE kind = $1 AND id = $2`,
208
- [kindName, id, Buffer.from(data)],
311
+ `UPDATE resources SET data = $3, updated_at = now(),
312
+ list_org = $4, list_created_at = $5, list_index_revision = $6, list_indexed_at = now()
313
+ WHERE kind = $1 AND id = $2`,
314
+ [
315
+ kindName,
316
+ id,
317
+ Buffer.from(data),
318
+ after?.org ?? null,
319
+ after?.createdAt ?? null,
320
+ after?.revision ?? null,
321
+ ],
209
322
  );
323
+ if (
324
+ after !== undefined &&
325
+ (!row.stamped ||
326
+ row.list_index_revision !== after.revision ||
327
+ before === undefined ||
328
+ !sameListKeyRows(before, after))
329
+ ) {
330
+ await replaceListKeys(client, kindName, id, after);
331
+ }
210
332
  return msg;
211
333
  });
212
334
  }
@@ -219,10 +341,73 @@ export class PostgresStore implements Store {
219
341
  return (result.rows as Array<{ data: Uint8Array }>).map((row) => row.data);
220
342
  }
221
343
 
344
+ async queryResources<K extends string>(
345
+ declaration: ListIndexDeclaration<K>,
346
+ query: ListIndexQuery<K>,
347
+ ): Promise<ListIndexRow[]> {
348
+ this.listIndexes.require(declaration);
349
+ assertListIndexLimit(query.limit);
350
+ if (query.anyKey !== undefined && query.anyKey.length === 0) {
351
+ return [];
352
+ }
353
+ const kindName = apiResourceKindName(declaration.kind);
354
+
355
+ // A key read is driven from the key table, ordered by that index's own
356
+ // columns, so the plan is one index range whatever the planner's
357
+ // statistics say (a freshly written table has none); several keys are
358
+ // one such read each, merged below. Without a key the organization
359
+ // index is the range.
360
+ const provenRows: ListIndexRow[] = [];
361
+ const reads =
362
+ query.anyKey === undefined
363
+ ? [provenReadSql(kindName, declaration.revision, query, undefined)]
364
+ : query.anyKey.map((key) =>
365
+ provenReadSql(kindName, declaration.revision, query, key),
366
+ );
367
+ for (const read of reads) {
368
+ const result = await this.open().query(read.sql, read.params);
369
+ for (const row of result.rows as Array<{
370
+ id: string;
371
+ data: Uint8Array;
372
+ created_at: string;
373
+ }>) {
374
+ provenRows.push({
375
+ id: row.id,
376
+ data: row.data,
377
+ cursor: { createdAt: row.created_at, id: row.id },
378
+ });
379
+ }
380
+ }
381
+
382
+ const unproven = await this.open().query(UNPROVEN_ROWS_SQL, [
383
+ kindName,
384
+ declaration.revision,
385
+ ]);
386
+ const unprovenRows: ListIndexRow[] = [];
387
+ for (const row of oneRowPerId(
388
+ unproven.rows as Array<{ id: string; data: Uint8Array }>,
389
+ )) {
390
+ const facts = await this.deriveAndRepair(declaration, row.id, row.data);
391
+ if (facts !== undefined && matchesListIndexQuery(row.id, facts, query)) {
392
+ unprovenRows.push({
393
+ id: row.id,
394
+ data: row.data,
395
+ cursor: { createdAt: facts.createdAt, id: row.id },
396
+ });
397
+ }
398
+ }
399
+ return mergeListIndexRows(provenRows, unprovenRows, query.limit);
400
+ }
401
+
222
402
  async deleteResource(kind: ApiResourceKind, id: string): Promise<void> {
403
+ const kindName = apiResourceKindName(kind);
223
404
  await this.open().query(
224
405
  `DELETE FROM resources WHERE kind = $1 AND id = $2`,
225
- [apiResourceKindName(kind), id],
406
+ [kindName, id],
407
+ );
408
+ await this.open().query(
409
+ `DELETE FROM resource_list_keys WHERE kind = $1 AND id = $2`,
410
+ [kindName, id],
226
411
  );
227
412
  }
228
413
 
@@ -291,24 +476,55 @@ export class PostgresStore implements Store {
291
476
  ): Promise<boolean> {
292
477
  // BYTEA equality in the WHERE clause makes the compare-and-swap one
293
478
  // atomic statement: zero rows changed means the row moved on (or was
294
- // deleted) since the read — a lost swap, never an upsert.
295
- const result = await this.open().query(
296
- `UPDATE resources SET data = $1, updated_at = now() WHERE kind = $2 AND id = $3 AND data = $4`,
297
- [
298
- Buffer.from(newData),
299
- apiResourceKindName(kind),
300
- id,
301
- Buffer.from(expectedData),
302
- ],
303
- );
304
- return result.rowCount === 1;
479
+ // deleted) since the read — a lost swap, never an upsert. The new bytes'
480
+ // list facts ride the same statement; bytes a declared kind's schema
481
+ // cannot decode leave the row unproven (stamp NULL), for a read to skip.
482
+ const kindName = apiResourceKindName(kind);
483
+ const declaration = this.listIndexes.declarationOf(kind);
484
+ const facts =
485
+ declaration === undefined
486
+ ? undefined
487
+ : factsOfBytes(declaration, newData);
488
+ const swap = async (client: Pool | PoolClient): Promise<boolean> => {
489
+ const result = await client.query(
490
+ `UPDATE resources SET data = $1, updated_at = now(),
491
+ list_org = $5, list_created_at = $6, list_index_revision = $7,
492
+ list_indexed_at = CASE WHEN $8::boolean THEN now() ELSE NULL END
493
+ WHERE kind = $2 AND id = $3 AND data = $4`,
494
+ [
495
+ Buffer.from(newData),
496
+ kindName,
497
+ id,
498
+ Buffer.from(expectedData),
499
+ facts?.org ?? null,
500
+ facts?.createdAt ?? null,
501
+ facts?.revision ?? null,
502
+ declaration === undefined || facts !== undefined,
503
+ ],
504
+ );
505
+ return result.rowCount === 1;
506
+ };
507
+ if (facts === undefined) {
508
+ return swap(this.open());
509
+ }
510
+ return this.withTransaction(async (client) => {
511
+ const swapped = await swap(client);
512
+ if (swapped) {
513
+ await replaceListKeys(client, kindName, id, facts);
514
+ }
515
+ return swapped;
516
+ });
305
517
  }
306
518
 
307
519
  async deleteResourcesByKind(kind: ApiResourceKind): Promise<number> {
520
+ const kindName = apiResourceKindName(kind);
308
521
  const result = await this.open().query(
309
522
  `DELETE FROM resources WHERE kind = $1`,
310
- [apiResourceKindName(kind)],
523
+ [kindName],
311
524
  );
525
+ await this.open().query(`DELETE FROM resource_list_keys WHERE kind = $1`, [
526
+ kindName,
527
+ ]);
312
528
  return result.rowCount ?? 0;
313
529
  }
314
530
 
@@ -318,13 +534,136 @@ export class PostgresStore implements Store {
318
534
  ): Promise<number> {
319
535
  // LIKE with the wildcard characters of the PREFIX escaped — sqlite's
320
536
  // GLOB has no LIKE-metacharacter hazard, this port must not either.
537
+ const params = [
538
+ apiResourceKindName(kind),
539
+ `${escapeLikePattern(idPrefix)}%`,
540
+ ];
321
541
  const result = await this.open().query(
322
542
  `DELETE FROM resources WHERE kind = $1 AND id LIKE $2 ESCAPE '\\'`,
323
- [apiResourceKindName(kind), `${escapeLikePattern(idPrefix)}%`],
543
+ params,
544
+ );
545
+ await this.open().query(
546
+ `DELETE FROM resource_list_keys WHERE kind = $1 AND id LIKE $2 ESCAPE '\\'`,
547
+ params,
324
548
  );
325
549
  return result.rowCount ?? 0;
326
550
  }
327
551
 
552
+ // ---------------------------------------------------------------------------
553
+ // The list index's repair (list-index.ts)
554
+ // ---------------------------------------------------------------------------
555
+
556
+ /**
557
+ * An unproven row's facts, derived from its bytes, and the row repaired
558
+ * when those bytes are still the stored ones — the compare-and-set on
559
+ * `data` means a newer write is never overwritten, and two replicas
560
+ * repairing one row write the same facts. Undefined when the bytes do
561
+ * not decode; the row stays unproven and is skipped, as a lane skips it.
562
+ */
563
+ private async deriveAndRepair(
564
+ declaration: ListIndexDeclaration,
565
+ id: string,
566
+ data: Uint8Array,
567
+ ): Promise<ListIndexFacts | undefined> {
568
+ const kindName = apiResourceKindName(declaration.kind);
569
+ const facts = factsOfBytes(declaration, data);
570
+ if (facts === undefined) {
571
+ this.logger.warn(
572
+ "list index: an unproven row does not decode, skipping",
573
+ {
574
+ kind: kindName,
575
+ id,
576
+ },
577
+ );
578
+ return undefined;
579
+ }
580
+ try {
581
+ await this.withTransaction(async (client) => {
582
+ const result = await client.query(
583
+ `UPDATE resources SET list_org = $4, list_created_at = $5,
584
+ list_index_revision = $6, list_indexed_at = updated_at
585
+ WHERE kind = $1 AND id = $2 AND data = $3`,
586
+ [
587
+ kindName,
588
+ id,
589
+ Buffer.from(data),
590
+ facts.org,
591
+ facts.createdAt,
592
+ facts.revision,
593
+ ],
594
+ );
595
+ if (result.rowCount === 1) {
596
+ await replaceListKeys(client, kindName, id, facts);
597
+ }
598
+ });
599
+ } catch (error) {
600
+ // The facts are right whether or not they were written back; a
601
+ // failed repair only leaves the row for the next read to repair.
602
+ this.logger.warn("list index: repairing a row failed", {
603
+ kind: kindName,
604
+ id,
605
+ error: error instanceof Error ? error.message : String(error),
606
+ });
607
+ }
608
+ return facts;
609
+ }
610
+
611
+ /**
612
+ * Derives every unproven row of each declared kind, in id-keyed batches,
613
+ * and stamps every other kind's unstamped rows (they carry no facts), so
614
+ * the unproven set is empty when the server starts serving. Reads stay
615
+ * exact without it; it keeps them cheap.
616
+ */
617
+ private async reconcileListIndexes(): Promise<void> {
618
+ const declarations = this.listIndexes.declarations();
619
+ let reconciled = false;
620
+ for (const declaration of declarations) {
621
+ const kindName = apiResourceKindName(declaration.kind);
622
+ let after = "";
623
+ let derived = 0;
624
+ for (;;) {
625
+ const batch = await this.open().query(
626
+ `SELECT id, data FROM (${UNPROVEN_ROWS_SQL}) unproven WHERE id > $3
627
+ ORDER BY id LIMIT $4`,
628
+ [kindName, declaration.revision, after, RECONCILE_BATCH],
629
+ );
630
+ // Duplicates from overlapping arms sit side by side in id order and
631
+ // the next batch starts after the last id, so none is repaired twice
632
+ // across batches and the count still ends the loop.
633
+ const rows = batch.rows as Array<{ id: string; data: Uint8Array }>;
634
+ for (const row of oneRowPerId(rows)) {
635
+ if (
636
+ (await this.deriveAndRepair(declaration, row.id, row.data)) !==
637
+ undefined
638
+ ) {
639
+ derived += 1;
640
+ }
641
+ after = row.id;
642
+ }
643
+ if (rows.length < RECONCILE_BATCH) {
644
+ break;
645
+ }
646
+ }
647
+ if (derived > 0) {
648
+ this.logger.info("list index reconciled", {
649
+ kind: kindName,
650
+ rows: derived,
651
+ });
652
+ reconciled = true;
653
+ }
654
+ }
655
+ if (reconciled) {
656
+ // Every derived row's list columns changed at once; the planner's
657
+ // statistics for them are refreshed before the first read plans.
658
+ await this.open().query(`ANALYZE resources, resource_list_keys`);
659
+ }
660
+ await this.open().query(
661
+ `UPDATE resources SET list_indexed_at = updated_at
662
+ WHERE list_indexed_at IS DISTINCT FROM updated_at AND NOT (kind = ANY($1::text[]))`,
663
+ [declarations.map((d) => apiResourceKindName(d.kind))],
664
+ );
665
+ }
666
+
328
667
  // ---------------------------------------------------------------------------
329
668
  // Audit operations
330
669
  // ---------------------------------------------------------------------------
@@ -1380,6 +1719,123 @@ class PostgresPendingOAuthStateStore implements PendingOAuthStateStore {
1380
1719
  }
1381
1720
  }
1382
1721
 
1722
+ /**
1723
+ * A row's key rows made equal to its facts: one row per key the row
1724
+ * names, the creation instant copied in so a parent's rows are one index
1725
+ * range. Runs on the caller's transaction, beside the row write it
1726
+ * belongs to.
1727
+ */
1728
+ async function replaceListKeys(
1729
+ client: PoolClient,
1730
+ kindName: string,
1731
+ id: string,
1732
+ facts: ListIndexFacts,
1733
+ ): Promise<void> {
1734
+ await client.query(
1735
+ `DELETE FROM resource_list_keys WHERE kind = $1 AND id = $2`,
1736
+ [kindName, id],
1737
+ );
1738
+ if (facts.keys.length === 0) {
1739
+ return;
1740
+ }
1741
+ const params: unknown[] = [kindName, id, facts.createdAt];
1742
+ const tuples = facts.keys.map((entry) => {
1743
+ params.push(entry.key, entry.value);
1744
+ return `($1, $2, $${params.length - 1}, $${params.length}, $3)`;
1745
+ });
1746
+ await client.query(
1747
+ `INSERT INTO resource_list_keys (kind, id, key, value, created_at) VALUES ${tuples.join(", ")}`,
1748
+ params,
1749
+ );
1750
+ }
1751
+
1752
+ /**
1753
+ * One proven read of `queryResources`: through one key's index range when
1754
+ * `key` is given (the key table drives, its copied creation instant is the
1755
+ * order), else through the organization index. Only rows whose facts are
1756
+ * proven current are returned; the unproven arm answers for the rest.
1757
+ */
1758
+ function provenReadSql(
1759
+ kindName: string,
1760
+ revision: number,
1761
+ query: ListIndexQuery,
1762
+ key: { readonly name: string; readonly value: string } | undefined,
1763
+ ): { sql: string; params: unknown[] } {
1764
+ const params: unknown[] = [kindName, revision];
1765
+ const param = (value: unknown): string => {
1766
+ params.push(value);
1767
+ return `$${params.length}`;
1768
+ };
1769
+ const created = key === undefined ? "r.list_created_at" : "k.created_at";
1770
+ const id = key === undefined ? `r.id COLLATE "C"` : `k.id COLLATE "C"`;
1771
+ const where = [
1772
+ `r.list_indexed_at = r.updated_at`,
1773
+ `r.list_index_revision = $2`,
1774
+ ];
1775
+ let from = `resources r`;
1776
+ if (key === undefined) {
1777
+ where.unshift(`r.kind = $1`);
1778
+ } else {
1779
+ // The row behind each key is fetched by primary key and nothing else:
1780
+ // `OFFSET 0` keeps the subquery from being flattened, so a planner
1781
+ // without statistics cannot reach it through the revision index
1782
+ // instead (measured: a scan of the kind per key row).
1783
+ from = `resource_list_keys k CROSS JOIN LATERAL (
1784
+ SELECT id, data, updated_at, list_indexed_at, list_index_revision, list_org
1785
+ FROM resources WHERE kind = k.kind AND id = k.id OFFSET 0
1786
+ ) r`;
1787
+ where.unshift(
1788
+ `k.kind = $1`,
1789
+ `k.key = ${param(key.name)}`,
1790
+ `k.value = ${param(key.value)}`,
1791
+ );
1792
+ }
1793
+ if (query.org !== undefined && query.org !== "") {
1794
+ where.push(`r.list_org = ${param(query.org)}`);
1795
+ }
1796
+ if (query.createdAtOrAfter !== undefined) {
1797
+ where.push(
1798
+ `(${created} >= ${param(query.createdAtOrAfter)} OR ${created} = '')`,
1799
+ );
1800
+ }
1801
+ if (query.after !== undefined) {
1802
+ const afterCreated = param(query.after.createdAt);
1803
+ const afterId = param(query.after.id);
1804
+ where.push(
1805
+ `(${created} < ${afterCreated} OR (${created} = ${afterCreated} AND ${id} < ${afterId}))`,
1806
+ );
1807
+ }
1808
+ const limit = query.limit === undefined ? "" : ` LIMIT ${param(query.limit)}`;
1809
+ return {
1810
+ sql: `SELECT r.id, r.data, ${created} AS created_at FROM ${from}
1811
+ WHERE ${where.join(" AND ")}
1812
+ ORDER BY ${created} DESC, ${id} DESC${limit}`,
1813
+ params,
1814
+ };
1815
+ }
1816
+
1817
+ /** Keeps the first row per id; the unproven arms overlap. */
1818
+ function oneRowPerId<T extends { readonly id: string }>(
1819
+ rows: ReadonlyArray<T>,
1820
+ ): T[] {
1821
+ const seen = new Set<string>();
1822
+ return rows.filter((row) =>
1823
+ seen.has(row.id) ? false : (seen.add(row.id), true),
1824
+ );
1825
+ }
1826
+
1827
+ /** A declared kind's facts from stored bytes; undefined when they do not decode. */
1828
+ function factsOfBytes(
1829
+ declaration: ListIndexDeclaration,
1830
+ data: Uint8Array,
1831
+ ): ListIndexFacts | undefined {
1832
+ try {
1833
+ return listIndexFactsOf(declaration, fromBinary(declaration.schema, data));
1834
+ } catch {
1835
+ return undefined;
1836
+ }
1837
+ }
1838
+
1383
1839
  /** Escapes LIKE metacharacters so a prefix matches literally. */
1384
1840
  function escapeLikePattern(value: string): string {
1385
1841
  return value