@hot-updater/server 1.0.0-rc.16 → 1.0.0-rc.18

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 (309) hide show
  1. package/dist/adapters/drizzle.cjs +5 -5
  2. package/dist/adapters/drizzle.mjs +4 -4
  3. package/dist/adapters/kysely.cjs +3 -3
  4. package/dist/adapters/kysely.mjs +2 -2
  5. package/dist/adapters/mongodb.cjs +3 -3
  6. package/dist/adapters/mongodb.mjs +2 -2
  7. package/dist/adapters/mongodbAdapter.cjs +19 -1
  8. package/dist/adapters/mongodbAdapter.mjs +19 -1
  9. package/dist/adapters/prisma.cjs +5 -5
  10. package/dist/adapters/prisma.mjs +4 -4
  11. package/dist/assembly/assemblePlugins.cjs +47 -28
  12. package/dist/assembly/assemblePlugins.d.cts +3 -7
  13. package/dist/assembly/assemblePlugins.d.mts +3 -7
  14. package/dist/assembly/assemblePlugins.mjs +46 -27
  15. package/dist/assembly/clientPlugins.cjs +86 -0
  16. package/dist/assembly/clientPlugins.d.cts +16 -0
  17. package/dist/assembly/clientPlugins.d.mts +16 -0
  18. package/dist/assembly/clientPlugins.mjs +86 -0
  19. package/dist/assembly/configError.cjs +7 -0
  20. package/dist/assembly/configError.d.cts +7 -0
  21. package/dist/assembly/configError.d.mts +7 -0
  22. package/dist/assembly/configError.mjs +7 -0
  23. package/dist/assembly/databasePlugins.cjs +5 -1
  24. package/dist/assembly/databasePlugins.d.cts +5 -5
  25. package/dist/assembly/databasePlugins.d.mts +5 -5
  26. package/dist/assembly/databasePlugins.mjs +5 -1
  27. package/dist/assembly/pluginCli.cjs +125 -0
  28. package/dist/assembly/pluginCli.d.cts +59 -0
  29. package/dist/assembly/pluginCli.d.mts +59 -0
  30. package/dist/assembly/pluginCli.mjs +121 -0
  31. package/dist/createHotUpdaterCore.cjs +14 -16
  32. package/dist/createHotUpdaterCore.d.cts +10 -3
  33. package/dist/createHotUpdaterCore.d.mts +10 -3
  34. package/dist/createHotUpdaterCore.mjs +11 -13
  35. package/dist/database/aggregateBatching.cjs +626 -0
  36. package/dist/database/aggregateBatching.d.cts +10 -0
  37. package/dist/database/aggregateBatching.d.mts +10 -0
  38. package/dist/database/aggregateBatching.mjs +625 -0
  39. package/dist/{insights/errors.cjs → database/busy.cjs} +2 -15
  40. package/dist/database/busy.d.cts +12 -0
  41. package/dist/database/busy.d.mts +12 -0
  42. package/dist/{insights/errors.mjs → database/busy.mjs} +3 -14
  43. package/dist/database/database.cjs +22 -3
  44. package/dist/database/database.d.cts +15 -2
  45. package/dist/database/database.d.mts +15 -2
  46. package/dist/database/database.mjs +22 -3
  47. package/dist/database/definitions.d.cts +10 -0
  48. package/dist/database/definitions.d.mts +10 -0
  49. package/dist/database/engine.d.cts +2 -1
  50. package/dist/database/engine.d.mts +2 -1
  51. package/dist/database/engineAggregates.cjs +7 -4
  52. package/dist/database/engineAggregates.mjs +8 -5
  53. package/dist/database/engineReads.cjs +5 -3
  54. package/dist/database/engineReads.mjs +6 -4
  55. package/dist/database/engineTransaction.cjs +2 -6
  56. package/dist/database/engineTransaction.mjs +2 -6
  57. package/dist/database/fence.cjs +2 -1
  58. package/dist/database/fence.mjs +2 -1
  59. package/dist/database/index.cjs +17 -8
  60. package/dist/database/index.d.cts +5 -4
  61. package/dist/database/index.d.mts +5 -4
  62. package/dist/database/index.mjs +6 -5
  63. package/dist/database/kv/kvAdapter.cjs +15 -3
  64. package/dist/database/kv/kvAdapter.d.cts +8 -0
  65. package/dist/database/kv/kvAdapter.d.mts +8 -0
  66. package/dist/database/kv/kvAdapter.mjs +16 -4
  67. package/dist/database/resolveSchema.cjs +4 -1
  68. package/dist/database/resolveSchema.d.cts +1 -1
  69. package/dist/database/resolveSchema.d.mts +1 -1
  70. package/dist/database/resolveSchema.mjs +4 -1
  71. package/dist/database/retention.cjs +92 -0
  72. package/dist/database/retention.mjs +90 -0
  73. package/dist/database/schema.cjs +5 -2
  74. package/dist/database/schema.d.cts +30 -1
  75. package/dist/database/schema.d.mts +30 -1
  76. package/dist/database/schema.mjs +5 -2
  77. package/dist/database/sql/sqlAdapter.cjs +8 -0
  78. package/dist/database/sql/sqlAdapter.mjs +9 -1
  79. package/dist/database/sql/sqlSchema.cjs +56 -0
  80. package/dist/database/sql/sqlSchema.mjs +55 -1
  81. package/dist/db/coreDatabase.cjs +85 -0
  82. package/dist/db/coreDatabase.d.cts +57 -0
  83. package/dist/db/coreDatabase.d.mts +57 -0
  84. package/dist/db/coreDatabase.mjs +78 -0
  85. package/dist/db/index.cjs +11 -3
  86. package/dist/db/index.d.cts +7 -3
  87. package/dist/db/index.d.mts +7 -3
  88. package/dist/db/index.mjs +6 -3
  89. package/dist/db/types.d.cts +3 -3
  90. package/dist/db/types.d.mts +3 -3
  91. package/dist/diff.cjs +3 -0
  92. package/dist/diff.d.cts +2 -0
  93. package/dist/diff.d.mts +2 -0
  94. package/dist/diff.mjs +2 -0
  95. package/dist/handler.cjs +2 -14
  96. package/dist/handler.mjs +1 -13
  97. package/dist/handlerReleaseCatalogRoutes.cjs +21 -2
  98. package/dist/handlerReleaseCatalogRoutes.mjs +21 -2
  99. package/dist/index.cjs +2 -6
  100. package/dist/index.d.cts +4 -7
  101. package/dist/index.d.mts +4 -7
  102. package/dist/index.mjs +2 -4
  103. package/dist/package.cjs +1 -1
  104. package/dist/package.mjs +1 -1
  105. package/dist/{apiKeys.cjs → plugins/api-keys/apiKeys.cjs} +1 -1
  106. package/dist/{apiKeys.d.cts → plugins/api-keys/apiKeys.d.cts} +2 -2
  107. package/dist/{apiKeys.d.mts → plugins/api-keys/apiKeys.d.mts} +2 -2
  108. package/dist/{apiKeys.mjs → plugins/api-keys/apiKeys.mjs} +1 -1
  109. package/dist/plugins/api-keys/cli.cjs +115 -0
  110. package/dist/plugins/api-keys/cli.mjs +114 -0
  111. package/dist/plugins/api-keys/index.cjs +9 -6
  112. package/dist/plugins/api-keys/index.d.cts +4 -5
  113. package/dist/plugins/api-keys/index.d.mts +4 -5
  114. package/dist/plugins/api-keys/index.mjs +9 -7
  115. package/dist/plugins/api-keys/model.d.cts +20 -2
  116. package/dist/plugins/api-keys/model.d.mts +20 -2
  117. package/dist/plugins/definePlugin.cjs +1 -1
  118. package/dist/plugins/definePlugin.d.cts +130 -3
  119. package/dist/plugins/definePlugin.d.mts +130 -3
  120. package/dist/plugins/definePlugin.mjs +1 -1
  121. package/dist/plugins/index.cjs +25 -4
  122. package/dist/plugins/index.d.cts +7 -4
  123. package/dist/plugins/index.d.mts +7 -4
  124. package/dist/plugins/index.mjs +6 -4
  125. package/dist/plugins/insights/contract.cjs +193 -0
  126. package/dist/plugins/insights/contract.mjs +189 -0
  127. package/dist/{insights → plugins/insights}/domain.d.cts +60 -15
  128. package/dist/{insights → plugins/insights}/domain.d.mts +60 -15
  129. package/dist/plugins/insights/errors.cjs +15 -0
  130. package/dist/plugins/insights/errors.mjs +14 -0
  131. package/dist/{insights → plugins/insights}/eventInput.cjs +155 -22
  132. package/dist/{insights → plugins/insights}/eventInput.mjs +155 -22
  133. package/dist/plugins/insights/eventRow.cjs +49 -0
  134. package/dist/plugins/insights/eventRow.d.cts +64 -0
  135. package/dist/plugins/insights/eventRow.d.mts +64 -0
  136. package/dist/plugins/insights/eventRow.mjs +47 -0
  137. package/dist/plugins/insights/index.cjs +106 -26
  138. package/dist/plugins/insights/index.d.cts +130 -27
  139. package/dist/plugins/insights/index.d.mts +130 -27
  140. package/dist/plugins/insights/index.mjs +106 -29
  141. package/dist/plugins/insights/model.cjs +2 -2
  142. package/dist/plugins/insights/model.d.cts +1 -1
  143. package/dist/plugins/insights/model.d.mts +1 -1
  144. package/dist/plugins/insights/model.mjs +1 -1
  145. package/dist/plugins/insights/modelTypes.d.cts +176 -0
  146. package/dist/plugins/insights/modelTypes.d.mts +176 -0
  147. package/dist/plugins/insights/overview.cjs +109 -0
  148. package/dist/plugins/insights/overview.d.cts +15 -0
  149. package/dist/plugins/insights/overview.d.mts +15 -0
  150. package/dist/plugins/insights/overview.mjs +107 -0
  151. package/dist/{insights → plugins/insights}/provider.cjs +33 -14
  152. package/dist/{insights → plugins/insights}/provider.d.cts +2 -2
  153. package/dist/{insights → plugins/insights}/provider.d.mts +2 -2
  154. package/dist/{insights → plugins/insights}/provider.mjs +30 -11
  155. package/dist/{insights → plugins/insights}/queryInput.cjs +2 -2
  156. package/dist/{insights → plugins/insights}/queryInput.mjs +2 -2
  157. package/dist/plugins/insights/reads.cjs +298 -88
  158. package/dist/plugins/insights/reads.d.cts +61 -0
  159. package/dist/plugins/insights/reads.d.mts +61 -0
  160. package/dist/plugins/insights/reads.mjs +298 -90
  161. package/dist/plugins/insights/recordEvent.cjs +223 -39
  162. package/dist/plugins/insights/recordEvent.d.cts +9 -4
  163. package/dist/plugins/insights/recordEvent.d.mts +9 -4
  164. package/dist/plugins/insights/recordEvent.mjs +221 -37
  165. package/dist/{insights → plugins/insights}/routes.cjs +3 -30
  166. package/dist/{insights → plugins/insights}/routes.mjs +4 -28
  167. package/dist/plugins/insights/schema.cjs +182 -40
  168. package/dist/plugins/insights/schema.d.cts +446 -6
  169. package/dist/plugins/insights/schema.d.mts +446 -6
  170. package/dist/plugins/insights/schema.mjs +180 -41
  171. package/dist/plugins/insights/testing/eventRow.d.mts +64 -0
  172. package/dist/plugins/insights/testing/expectInsightsIndex.mjs +18 -0
  173. package/dist/plugins/insights/testing/fixtures.d.mts +8 -0
  174. package/dist/plugins/insights/testing/fixtures.mjs +25 -0
  175. package/dist/plugins/insights/testing/index.d.mts +24 -0
  176. package/dist/plugins/insights/testing/index.mjs +25 -0
  177. package/dist/plugins/insights/testing/insightsModelTests.mjs +714 -0
  178. package/dist/plugins/insights/testing/modelTypes.d.mts +176 -0
  179. package/dist/plugins/insights/testing/setupInsightsHttpTestSuite.d.mts +11 -0
  180. package/dist/plugins/insights/testing/setupInsightsHttpTestSuite.mjs +107 -0
  181. package/dist/plugins/insights/testing/setupInsightsModelTestSuite.d.mts +11 -0
  182. package/dist/plugins/insights/testing/setupInsightsModelTestSuite.mjs +31 -0
  183. package/dist/plugins/insights/testing/types.d.mts +26 -0
  184. package/dist/{insights → plugins/insights}/types.d.cts +3 -2
  185. package/dist/{insights → plugins/insights}/types.d.mts +3 -2
  186. package/package.json +18 -7
  187. package/src/adapters/drizzle.conformance.integration.spec.ts +2 -2
  188. package/src/adapters/drizzle.readBudgets.integration.spec.ts +6 -4
  189. package/src/adapters/drizzle.spec.ts +15 -7
  190. package/src/adapters/drizzle.ts +6 -6
  191. package/src/adapters/kysely.conformance.integration.spec.ts +2 -2
  192. package/src/adapters/kysely.readBudgets.integration.spec.ts +4 -2
  193. package/src/adapters/kysely.spec.ts +13 -9
  194. package/src/adapters/kysely.ts +2 -2
  195. package/src/adapters/mongodb.integration.spec.ts +98 -7
  196. package/src/adapters/mongodb.ts +2 -2
  197. package/src/adapters/mongodbAdapter.ts +14 -0
  198. package/src/adapters/prisma.conformance.integration.spec.ts +2 -2
  199. package/src/adapters/prisma.integration.spec.ts +16 -8
  200. package/src/adapters/prisma.readBudgets.integration.spec.ts +5 -2
  201. package/src/adapters/prisma.spec.ts +17 -9
  202. package/src/adapters/prisma.ts +6 -6
  203. package/src/adapters/prismaTestClients.ts +8 -1
  204. package/src/assembly/assemblePlugins.ts +73 -40
  205. package/src/assembly/assembly.spec.ts +63 -11
  206. package/src/assembly/assembly.types.spec.ts +50 -2
  207. package/src/assembly/clientPlugins.ts +113 -0
  208. package/src/assembly/configError.ts +4 -0
  209. package/src/assembly/databasePlugins.ts +11 -5
  210. package/src/assembly/pluginCli.spec.ts +254 -0
  211. package/src/assembly/pluginCli.ts +248 -0
  212. package/src/assembly/{builtInRoutes.spec.ts → pluginRoutes.spec.ts} +10 -37
  213. package/src/createHotUpdaterCore.ts +21 -17
  214. package/src/database/aggregateBatching.spec.ts +577 -0
  215. package/src/database/aggregateBatching.ts +803 -0
  216. package/src/{insights/errors.spec.ts → database/busy.spec.ts} +2 -5
  217. package/src/{insights/errors.ts → database/busy.ts} +1 -13
  218. package/src/database/database.ts +39 -8
  219. package/src/database/definitions.ts +4 -0
  220. package/src/database/engineAggregates.spec.ts +4 -7
  221. package/src/database/engineAggregates.ts +5 -9
  222. package/src/database/engineReads.ts +7 -5
  223. package/src/database/engineTransaction.ts +1 -7
  224. package/src/database/fence.spec.ts +5 -5
  225. package/src/database/fence.ts +4 -0
  226. package/src/database/index.ts +13 -7
  227. package/src/database/kv/kvAdapter.batching.integration.spec.ts +15 -0
  228. package/src/database/kv/kvAdapter.conformance.integration.spec.ts +2 -0
  229. package/src/database/kv/kvAdapter.spec.ts +128 -1
  230. package/src/database/kv/kvAdapter.ts +30 -1
  231. package/src/database/kv/kvTestStore.ts +28 -1
  232. package/src/database/resolveSchema.ts +4 -1
  233. package/src/database/retention.spec.ts +314 -0
  234. package/src/database/retention.ts +129 -0
  235. package/src/database/schema.ts +44 -0
  236. package/src/database/sql/sqlAdapter.integration.spec.ts +4 -7
  237. package/src/database/sql/sqlAdapter.readBudgets.integration.spec.ts +18 -4
  238. package/src/database/sql/sqlAdapter.spec.ts +91 -3
  239. package/src/database/sql/sqlAdapter.ts +20 -0
  240. package/src/database/sql/sqlSchema.ts +81 -0
  241. package/src/databaseTestSuite.spec.ts +9 -4
  242. package/src/db/coreDatabase.ts +153 -0
  243. package/src/db/engineSql.spec.ts +8 -7
  244. package/src/db/index.ts +19 -2
  245. package/src/db/pluginTables.spec.ts +57 -27
  246. package/src/db/types.ts +3 -3
  247. package/src/diff.ts +6 -0
  248. package/src/handler-standalone.integration.spec.ts +2 -3
  249. package/src/handler.ts +1 -32
  250. package/src/handlerReleaseCatalogRoutes.ts +26 -2
  251. package/src/index.ts +0 -10
  252. package/src/{apiKeys.integration.spec.ts → plugins/api-keys/apiKeys.integration.spec.ts} +4 -4
  253. package/src/{apiKeys.spec.ts → plugins/api-keys/apiKeys.spec.ts} +3 -3
  254. package/src/{apiKeys.ts → plugins/api-keys/apiKeys.ts} +1 -1
  255. package/src/plugins/api-keys/apiKeysPlugin.spec.ts +11 -15
  256. package/src/plugins/api-keys/cli.spec.ts +187 -0
  257. package/src/plugins/api-keys/cli.ts +140 -0
  258. package/src/plugins/api-keys/index.ts +48 -41
  259. package/src/plugins/api-keys/model.ts +21 -2
  260. package/src/plugins/definePlugin.ts +144 -2
  261. package/src/plugins/index.ts +20 -2
  262. package/src/plugins/insights/contract.spec.ts +285 -0
  263. package/src/plugins/insights/contract.ts +372 -0
  264. package/src/{insights → plugins/insights}/domain.ts +73 -15
  265. package/src/plugins/insights/errors.ts +11 -0
  266. package/src/{insights → plugins/insights}/eventInput.ts +189 -29
  267. package/src/plugins/insights/eventRow.ts +209 -0
  268. package/src/plugins/insights/index.ts +181 -45
  269. package/src/{insights → plugins/insights}/insights.integration.spec.ts +330 -34
  270. package/src/plugins/insights/insightsRollout.integration.spec.ts +1 -2
  271. package/src/plugins/insights/model.spec.ts +3 -5
  272. package/src/plugins/insights/model.ts +2 -3
  273. package/src/plugins/insights/modelTypes.ts +197 -0
  274. package/src/plugins/insights/overview.spec.ts +64 -0
  275. package/src/plugins/insights/overview.ts +214 -0
  276. package/src/{insights → plugins/insights}/provider.spec.ts +21 -10
  277. package/src/{insights → plugins/insights}/provider.ts +58 -21
  278. package/src/{insights → plugins/insights}/queryInput.ts +2 -2
  279. package/src/plugins/insights/reads.spec.ts +340 -52
  280. package/src/plugins/insights/reads.ts +453 -123
  281. package/src/plugins/insights/recordEvent.spec.ts +474 -20
  282. package/src/plugins/insights/recordEvent.ts +301 -63
  283. package/src/plugins/insights/retention.spec.ts +115 -0
  284. package/src/{insights → plugins/insights}/routes.ts +1 -31
  285. package/src/plugins/insights/schema.ts +356 -176
  286. package/src/plugins/insights/testing/expectInsightsIndex.spec.ts +42 -0
  287. package/src/plugins/insights/testing/expectInsightsIndex.ts +20 -0
  288. package/src/plugins/insights/testing/fixtures.ts +30 -0
  289. package/src/plugins/insights/testing/index.ts +45 -0
  290. package/src/plugins/insights/testing/insightsModelTests.ts +860 -0
  291. package/src/plugins/insights/testing/setupInsightsHttpTestSuite.ts +152 -0
  292. package/src/plugins/insights/testing/setupInsightsModelTestSuite.ts +36 -0
  293. package/src/plugins/insights/testing/types.ts +30 -0
  294. package/src/{insights → plugins/insights}/types.ts +1 -1
  295. package/src/readBudgets.testFixtures.ts +2 -2
  296. package/src/releaseCatalogRoutes.integration.spec.ts +7 -0
  297. package/src/retention.spec.ts +73 -0
  298. package/src/runtime.spec.ts +11 -12
  299. package/src/runtime.testFixtures.ts +7 -2
  300. package/src/testUtilsMutation.integration.spec.ts +2 -1
  301. package/src/testUtilsPackage.integration.spec.ts +7 -3
  302. package/dist/db/builtInDatabase.cjs +0 -104
  303. package/dist/db/builtInDatabase.d.cts +0 -38
  304. package/dist/db/builtInDatabase.d.mts +0 -38
  305. package/dist/db/builtInDatabase.mjs +0 -97
  306. package/dist/plugins/builtIn.cjs +0 -7
  307. package/dist/plugins/builtIn.mjs +0 -6
  308. package/src/db/builtInDatabase.ts +0 -149
  309. package/src/plugins/builtIn.ts +0 -5
@@ -40,6 +40,8 @@ const ready = async (connect: () => Promise<unknown>) => {
40
40
  }
41
41
  };
42
42
 
43
+ const MYSQL_URI = "mysql://root:hot_updater@127.0.0.1:53306/hot_updater";
44
+
43
45
  let postgres: pg.Pool;
44
46
  let mariadb: mysql.Pool;
45
47
 
@@ -50,11 +52,23 @@ beforeAll(async () => {
50
52
  "postgres://postgres:hot_updater@127.0.0.1:55432/hot_updater",
51
53
  max: 16,
52
54
  });
53
- mariadb = mysql.createPool({
54
- uri: "mysql://root:hot_updater@127.0.0.1:53306/hot_updater",
55
- connectionLimit: 16,
56
- });
57
55
  await ready(() => postgres.query("SELECT 1"));
56
+ // Each row a plan evaluates costs as much as an index entry it reads, as
57
+ // PostgreSQL's planner settings make it, so a small table plans like a
58
+ // production-sized one: an index range rather than a primary-key prefix
59
+ // filtered down to it. Sessions opened after the flush use the new cost.
60
+ await ready(async () => {
61
+ const admin = await mysql.createConnection(MYSQL_URI);
62
+ try {
63
+ await admin.query(
64
+ "UPDATE mysql.server_cost SET cost_value = 1 WHERE cost_name = 'row_evaluate_cost'",
65
+ );
66
+ await admin.query("FLUSH OPTIMIZER_COSTS");
67
+ } finally {
68
+ await admin.end();
69
+ }
70
+ });
71
+ mariadb = mysql.createPool({ uri: MYSQL_URI, connectionLimit: 16 });
58
72
  await ready(() => mariadb.query("SELECT 1"));
59
73
  }, 180_000);
60
74
 
@@ -4,12 +4,13 @@ import { PGlite } from "@electric-sql/pglite";
4
4
  import type { PhysicalTable } from "@hot-updater/plugin-core/internal";
5
5
  import {
6
6
  conformanceCounters,
7
+ conformanceExpiring,
7
8
  conformanceItems,
8
9
  setupDatabaseAdapterConformanceSuite,
9
10
  } from "@hot-updater/test-utils";
10
11
  import { afterAll, describe, expect, it } from "vitest";
11
12
 
12
- import { builtInSchema } from "../../db/builtInDatabase";
13
+ import { coreSchema } from "../../db/coreDatabase";
13
14
  import {
14
15
  classifySqlError,
15
16
  createSqlAdapter,
@@ -18,6 +19,7 @@ import {
18
19
  type SqlStatement,
19
20
  WRITE_GUARD_TABLE,
20
21
  } from "./sqlAdapter";
22
+ import { pruneStatements } from "./sqlSchema";
21
23
  import {
22
24
  pgliteBatchExecutor,
23
25
  pgliteExecutor,
@@ -145,9 +147,9 @@ describe("sql core", () => {
145
147
  expect(() => createTableStatements("mysql", [catalogs(false)])).toThrow(
146
148
  "catalogs key needs 8192 bytes on MySQL, over its 3072",
147
149
  );
148
- // every table the façade spans fits
150
+ // every core table fits; the plugins' tables are checked beside them
149
151
  expect(() =>
150
- createTableStatements("mysql", builtInSchema.tables),
152
+ createTableStatements("mysql", coreSchema.tables),
151
153
  ).not.toThrow();
152
154
  });
153
155
 
@@ -236,6 +238,92 @@ describe("sql core", () => {
236
238
  ]);
237
239
  });
238
240
 
241
+ it("indexes a retention column that no index or key already leads with", () => {
242
+ const index = (table: PhysicalTable) =>
243
+ createTableStatements("postgresql", [table]).filter((statement) =>
244
+ statement.includes("__retention"),
245
+ );
246
+ expect(index(conformanceExpiring)).toEqual([
247
+ 'CREATE INDEX IF NOT EXISTS "conformance_expiring__retention" ON "conformance_expiring" ("at", "id")',
248
+ ]);
249
+ // An index or a key that starts with the column serves a prune already.
250
+ const led = (retention: PhysicalTable["retention"]) =>
251
+ index({ ...conformanceCounters, retention });
252
+ expect(led({ column: "scope", ms: 1 })).toEqual([]);
253
+ expect(
254
+ index({
255
+ ...conformanceExpiring,
256
+ indexes: [{ name: "byAt", eq: ["at"], sort: [] }],
257
+ }),
258
+ ).toEqual([]);
259
+ expect(led({ column: "hits", ms: 1 })).toEqual([
260
+ 'CREATE INDEX IF NOT EXISTS "conformance_counters__retention" ON "conformance_counters" ("hits", "scope", "shard")',
261
+ ]);
262
+ });
263
+
264
+ it("walks expired rows in the order of the index that leads with the retention column", () => {
265
+ const events: PhysicalTable = {
266
+ ...conformanceExpiring,
267
+ columns: [
268
+ ...conformanceExpiring.columns,
269
+ { name: "received", type: "integer", nullable: false },
270
+ ],
271
+ indexes: [{ name: "byAt", eq: ["at"], sort: ["received"] }],
272
+ };
273
+ const [postgres] = pruneStatements("postgresql", events, "", 10).map(
274
+ (render) => render(() => "$1"),
275
+ );
276
+ expect(postgres).toBe(
277
+ 'DELETE FROM "conformance_expiring" WHERE "conformance_expiring"."at" <= $1 AND ("conformance_expiring"."at", "conformance_expiring"."received", "conformance_expiring"."id") <= (SELECT b."at", b."received", b."id" FROM (SELECT i."at", i."received", i."id" FROM "conformance_expiring" i WHERE i."at" <= $1 ORDER BY i."at" ASC, i."received" ASC, i."id" ASC LIMIT 10) b ORDER BY b."at" DESC, b."received" DESC, b."id" DESC LIMIT 1)',
278
+ );
279
+ const [entries, rows] = pruneStatements(
280
+ "mysql",
281
+ conformanceExpiring,
282
+ "",
283
+ 10,
284
+ ).map((render) => render(() => "?"));
285
+ expect(entries).toBe(
286
+ "DELETE e FROM `conformance_expiring__byTag` e JOIN (SELECT i.`at`, i.`id` FROM `conformance_expiring` i WHERE i.`at` <= ? ORDER BY i.`at` ASC, i.`id` ASC LIMIT 10) d ON e.`id` = d.`id`",
287
+ );
288
+ expect(rows).toBe(
289
+ "DELETE FROM `conformance_expiring` WHERE `at` <= ? ORDER BY `at`, `id` LIMIT 10",
290
+ );
291
+ });
292
+
293
+ it.each(["PGlite", "SQLite"] as const)(
294
+ "prunes expired rows with their index table entries on %s",
295
+ async (engine) => {
296
+ const sqlite = engine === "SQLite" ? new DatabaseSync(":memory:") : null;
297
+ tests += 1;
298
+ const executor = sqlite ? sqliteExecutor(sqlite) : pgliteExecutor(pglite);
299
+ const prefix = sqlite ? "" : `t${tests}_`;
300
+ const adapter = createSqlAdapter({ executor, tablePrefix: prefix });
301
+ await adapter.migrations!.apply([conformanceExpiring]);
302
+ await adapter.write(
303
+ [5, 10, 20, null].map((at, position) => ({
304
+ type: "insert",
305
+ table: conformanceExpiring,
306
+ row: { id: `r${position}`, grp: "g", at, tags: ["a", "b"], _v: 0 },
307
+ })),
308
+ );
309
+ const count = async (name: string) =>
310
+ Number(
311
+ (
312
+ await executor.execute({
313
+ sql: `SELECT COUNT(*) AS n FROM "${prefix}${name}"`,
314
+ params: [],
315
+ })
316
+ ).rows[0]!.n,
317
+ );
318
+
319
+ expect(await adapter.prune!(conformanceExpiring, 10, 500)).toBe(2);
320
+ expect(await count("conformance_expiring")).toBe(2);
321
+ // Each row had two tag entries; the pruned rows' went with them.
322
+ expect(await count("conformance_expiring__byTag")).toBe(4);
323
+ sqlite?.close();
324
+ },
325
+ );
326
+
239
327
  it("classifies constraint and transient driver errors", () => {
240
328
  const withCode = (fields: Record<string, unknown>) =>
241
329
  Object.assign(new Error("driver"), fields);
@@ -20,6 +20,7 @@ import {
20
20
  import {
21
21
  createTableStatements,
22
22
  isMultiIndex,
23
+ pruneStatements,
23
24
  quoteSql,
24
25
  type SqlDialect,
25
26
  } from "./sqlSchema";
@@ -515,6 +516,25 @@ export const createSqlAdapter = ({
515
516
  }
516
517
  },
517
518
 
519
+ async prune(table, before, limit) {
520
+ const statements = pruneStatements(
521
+ dialect,
522
+ table,
523
+ tablePrefix,
524
+ limit,
525
+ ).map((render) => sql((bind) => render(() => bind(before))));
526
+ const results = executor.batch
527
+ ? await executor.batch(statements)
528
+ : await executor.transaction(async (connection) => {
529
+ const done: SqlResult[] = [];
530
+ for (const statement of statements) {
531
+ done.push(await connection.execute(statement));
532
+ }
533
+ return done;
534
+ });
535
+ return results.at(-1)?.changes ?? 0;
536
+ },
537
+
518
538
  migrations: {
519
539
  async apply(tables) {
520
540
  // A batching executor's writes also need the write guard table.
@@ -155,6 +155,16 @@ export const sqlTableShapes = (
155
155
  },
156
156
  ];
157
157
  });
158
+ // A prune walks expired rows in an index's order, so one leads with the column.
159
+ const retention = table.retention && retentionOrder(table);
160
+ if (retention && !retention.indexed) {
161
+ indexes.push({
162
+ kind: "index",
163
+ name: shortSqlName(`${name}__retention`),
164
+ unique: false,
165
+ columns: retention.columns,
166
+ });
167
+ }
158
168
  return {
159
169
  name,
160
170
  columns: table.columns.map((column) => ({
@@ -203,3 +213,74 @@ export const createTableStatements = (
203
213
  };
204
214
  return sqlTableShapes(dialect, tables, tablePrefix).flatMap(render);
205
215
  };
216
+
217
+ /**
218
+ * The order a prune walks a table's expired rows in, which ends with the
219
+ * key so it is total: the columns of the first index that leads with the
220
+ * retention column, or the key when it does, or else the column and the
221
+ * key, which then need an index of their own (`indexed` false).
222
+ */
223
+ export const retentionOrder = (table: PhysicalTable) => {
224
+ const { column } = table.retention!;
225
+ for (const index of table.indexes) {
226
+ const columns = [...index.eq, ...indexOrderColumns(table, index)];
227
+ if (!index.unique && !isMultiIndex(table, index) && columns[0] === column) {
228
+ return { columns, indexed: true };
229
+ }
230
+ }
231
+ if (table.key[0] === column) return { columns: table.key, indexed: true };
232
+ const key = table.key.filter((field) => field !== column);
233
+ return { columns: [column, ...key], indexed: false };
234
+ };
235
+
236
+ /**
237
+ * The statements that delete up to `limit` expired rows of a table with
238
+ * retention, oldest first in {@link retentionOrder}: its multi-valued index
239
+ * entries, then the rows, which a concurrent write that moved the retention
240
+ * column past `before` keeps. `before` renders the bound each time it
241
+ * appears. MySQL deletes with ORDER BY and LIMIT. Elsewhere the cutoff is
242
+ * the last of the `limit` oldest expired rows in that order, a row value
243
+ * the index ranges over, and the shapes pass Supabase's apply RPC: no IN, no
244
+ * AS, aliases `i` and `b`.
245
+ */
246
+ export const pruneStatements = (
247
+ dialect: SqlDialect,
248
+ table: PhysicalTable,
249
+ tablePrefix: string,
250
+ limit: number,
251
+ ): ((before: () => string) => string)[] => {
252
+ const quote = (name: string) => quoteSql(dialect, name);
253
+ const name = quote(`${tablePrefix}${table.name}`);
254
+ const at = quote(table.retention!.column);
255
+ const order = retentionOrder(table).columns.map(quote);
256
+ const list = (alias: string, direction = "") =>
257
+ order.map((column) => `${alias}.${column}${direction}`).join(", ");
258
+ const oldest = (before: () => string) =>
259
+ `SELECT ${list("i")} FROM ${name} i WHERE i.${at} <= ${before()} ORDER BY ${list("i", " ASC")} LIMIT ${limit}`;
260
+ const doomed = (alias: string, before: () => string) =>
261
+ `${alias}.${at} <= ${before()} AND (${list(alias)}) <= (SELECT ${list("b")} FROM (${oldest(before)}) b ORDER BY ${list("b", " DESC")} LIMIT 1)`;
262
+ const entries = table.indexes
263
+ .filter((index) => isMultiIndex(table, index))
264
+ .map((index) => quote(`${tablePrefix}${table.name}__${index.name}`));
265
+ const keyOf = (left: string, right: string) =>
266
+ table.key
267
+ .map((column) => `${left}.${quote(column)} = ${right}.${quote(column)}`)
268
+ .join(" AND ");
269
+ if (dialect === "mysql") {
270
+ return [
271
+ ...entries.map(
272
+ (index) => (before: () => string) =>
273
+ `DELETE e FROM ${index} e JOIN (${oldest(before)}) d ON ${keyOf("e", "d")}`,
274
+ ),
275
+ (before) =>
276
+ `DELETE FROM ${name} WHERE ${at} <= ${before()} ORDER BY ${order.join(", ")} LIMIT ${limit}`,
277
+ ];
278
+ }
279
+ return [
280
+ ...entries.map(
281
+ (index) => (before: () => string) =>
282
+ `DELETE FROM ${index} WHERE EXISTS (SELECT * FROM ${name} b WHERE ${keyOf("b", index)} AND ${doomed("b", before)})`,
283
+ ),
284
+ (before) => `DELETE FROM ${name} WHERE ${doomed(name, before)}`,
285
+ ];
286
+ };
@@ -7,6 +7,7 @@ import {
7
7
  import { createDatabasePluginApis } from "./assembly/databasePlugins";
8
8
  import { createHotUpdater } from "./index";
9
9
  import { createInsightsModel, insights } from "./plugins/insights";
10
+ import { insightsTestSuite } from "./plugins/insights/testing";
10
11
 
11
12
  let adapter = createMemoryAdapter();
12
13
 
@@ -32,8 +33,12 @@ setupDatabaseTestSuite({
32
33
  clientAccess: "public",
33
34
  }).handlers,
34
35
  ),
35
- createInsightsModel: (database) =>
36
- createInsightsModel(
37
- createDatabasePluginApis(database, [insights()]).insights,
38
- ),
36
+ plugins: [
37
+ insightsTestSuite({
38
+ createModel: (database) =>
39
+ createInsightsModel(
40
+ createDatabasePluginApis(database, [insights()]).insights,
41
+ ),
42
+ }),
43
+ ],
39
44
  });
@@ -0,0 +1,153 @@
1
+ import type { AggregateBatching } from "@hot-updater/plugin-core";
2
+ import type { DatabaseAdapter } from "@hot-updater/plugin-core/internal";
3
+
4
+ import { coreModule, HOT_UPDATER_SCHEMA_VERSION } from "../core/schema";
5
+ import type { ModelShape } from "../database/definitions";
6
+ import {
7
+ ENGINE_SCHEMA_KEY,
8
+ ENGINE_SCHEMA_VERSION,
9
+ isMissingSchemaError,
10
+ readSettings,
11
+ withSchemaFence,
12
+ type SchemaSettings,
13
+ } from "../database/fence";
14
+ import { resolveSchema } from "../database/resolveSchema";
15
+ import { createEngineMigrator } from "./engineMigrator";
16
+ import { migrateSchema } from "./schemaSettings";
17
+ import type { ToolingDatabase, ToolingTarget } from "./types";
18
+
19
+ /** Core's tables: the tables every provider's tooling creates. */
20
+ export const coreSchema = resolveSchema([coreModule]);
21
+
22
+ /** The settings rows a provider's database is fenced by. */
23
+ export const coreSettings: SchemaSettings = {
24
+ [ENGINE_SCHEMA_KEY]: ENGINE_SCHEMA_VERSION,
25
+ "schema.core": HOT_UPDATER_SCHEMA_VERSION,
26
+ };
27
+
28
+ /** What `hot-updater db` creates for a server without plugins. */
29
+ export const coreTarget: ToolingTarget = {
30
+ schema: coreSchema,
31
+ settings: coreSettings,
32
+ };
33
+
34
+ /** A plugin as the tooling reads it: its id, its tables, and their version. */
35
+ export interface PluginTables {
36
+ readonly id: string;
37
+ readonly schemaVersion: string;
38
+ readonly schema: Readonly<Record<string, ModelShape>>;
39
+ /** `false` keeps the declared table names instead of prefixing them with the id. */
40
+ readonly namespace?: false;
41
+ }
42
+
43
+ /** Each plugin's `schema.<id>` settings row. */
44
+ export const pluginSettings = (
45
+ plugins: readonly PluginTables[],
46
+ ): SchemaSettings =>
47
+ Object.fromEntries(
48
+ plugins.map(({ id, schemaVersion }) => [`schema.${id}`, schemaVersion]),
49
+ );
50
+
51
+ /** A plugin's schema as the engine resolves it: prefixed by its id unless it keeps its names. */
52
+ export const pluginModule = <TSchema>({
53
+ id,
54
+ schema,
55
+ namespace,
56
+ }: {
57
+ readonly id: string;
58
+ readonly schema: TSchema;
59
+ readonly namespace?: false;
60
+ }) => ({
61
+ id,
62
+ schema,
63
+ ...(namespace === false ? {} : { namespace: id }),
64
+ });
65
+
66
+ /**
67
+ * What `hot-updater db` creates for a server's plugins: core's tables, then
68
+ * each plugin's tables with its `schema.<id>` settings row.
69
+ */
70
+ export const toolingTargetOf = (
71
+ plugins: readonly PluginTables[],
72
+ ): ToolingTarget =>
73
+ plugins.length === 0
74
+ ? coreTarget
75
+ : {
76
+ schema: resolveSchema([coreModule, ...plugins.map(pluginModule)]),
77
+ settings: { ...coreSettings, ...pluginSettings(plugins) },
78
+ };
79
+
80
+ /**
81
+ * Creates core's tables and those of `plugins`, then writes their settings
82
+ * rows: what a provider's setup runs for the plugins its server runs.
83
+ */
84
+ export const migrateCoreSchema = (
85
+ adapter: DatabaseAdapter,
86
+ name: string,
87
+ plugins: readonly PluginTables[] = [],
88
+ ) => {
89
+ const { schema, settings } = toolingTargetOf(plugins);
90
+ return migrateSchema(adapter, name, schema.tables, settings);
91
+ };
92
+
93
+ export interface EngineDatabaseOptions {
94
+ readonly name: string;
95
+ readonly adapter: DatabaseAdapter;
96
+ /**
97
+ * Purges a CDN's copies of the cacheable client routes. Core calls it after
98
+ * a committed write that changes what those routes answer, since only core
99
+ * knows which of its tables they read.
100
+ */
101
+ readonly onCachedRoutesChange?: () => Promise<void>;
102
+ /**
103
+ * Batches aggregates declared `batched`, for stores that bill each write;
104
+ * `false` or absent commits them with each transaction.
105
+ */
106
+ readonly aggregateBatching?: AggregateBatching | false;
107
+ }
108
+
109
+ /**
110
+ * A provider's database on the storage engine: its adapter behind the schema
111
+ * fence, which checks core's settings rows before the first read or write
112
+ * and names `hot-updater db migrate` when they are missing or stale. When the
113
+ * adapter creates its own tables, `hot-updater db migrate` creates core's
114
+ * tables and the server's plugin tables, then writes their settings rows.
115
+ */
116
+ export const createEngineDatabase = ({
117
+ name,
118
+ adapter,
119
+ onCachedRoutesChange,
120
+ aggregateBatching,
121
+ }: EngineDatabaseOptions): ToolingDatabase => {
122
+ const fenced = withSchemaFence(adapter, name, coreSettings);
123
+ const createMigrator = ({ schema, settings } = coreTarget) =>
124
+ createEngineMigrator({
125
+ adapterName: name,
126
+ adapter,
127
+ schema,
128
+ settings,
129
+ readSettings: async () => {
130
+ const rows = await readSettings(adapter, Object.keys(settings)).catch(
131
+ (error: unknown) => {
132
+ if (isMissingSchemaError(error)) return [];
133
+ throw error;
134
+ },
135
+ );
136
+ return new Map(
137
+ rows.flatMap((row) =>
138
+ row === null ? [] : [[String(row.key), row.value]],
139
+ ),
140
+ );
141
+ },
142
+ });
143
+ return {
144
+ name,
145
+ adapter: fenced,
146
+ ...(onCachedRoutesChange === undefined ? {} : { onCachedRoutesChange }),
147
+ ...(aggregateBatching ? { aggregateBatching } : {}),
148
+ ...(adapter.dispose === undefined
149
+ ? {}
150
+ : { dispose: () => adapter.dispose!() }),
151
+ ...(adapter.migrations === undefined ? {} : { createMigrator }),
152
+ };
153
+ };
@@ -15,15 +15,16 @@ import {
15
15
  pgliteExecutor,
16
16
  sqliteExecutor,
17
17
  } from "../database/sql/sqlTestExecutors";
18
- import {
19
- builtInSchema,
20
- builtInSettings,
21
- createEngineDatabase,
22
- } from "./builtInDatabase";
18
+ import { apiKeys } from "../plugins/api-keys";
19
+ import { insights } from "../plugins/insights";
20
+ import { createEngineDatabase, toolingTargetOf } from "./coreDatabase";
23
21
  import { generateEngineSql } from "./engineSql";
24
22
 
23
+ /** Core's tables and the Insights and API keys plugins'. */
24
+ const target = toolingTargetOf([insights(), apiKeys()]);
25
+
25
26
  const sql = (dialect: "postgresql" | "mysql" | "sqlite") =>
26
- generateEngineSql(dialect, builtInSchema, builtInSettings);
27
+ generateEngineSql(dialect, target.schema, target.settings);
27
28
 
28
29
  describe("the shared SQL schema", () => {
29
30
  it("defaults engine columns, has no foreign keys, and writes the settings last", () => {
@@ -53,7 +54,7 @@ describe("the shared SQL schema", () => {
53
54
  });
54
55
 
55
56
  it("describes each table as its DDL creates it, for ORM schema generators", () => {
56
- const tables = builtInSchema.tables;
57
+ const tables = target.schema.tables;
57
58
  const names = (shapes: readonly SqlTableShape[]): string[] =>
58
59
  shapes.flatMap((shape) => [
59
60
  shape.name,
package/src/db/index.ts CHANGED
@@ -2,6 +2,7 @@ import {
2
2
  getHotUpdaterCoreMetadata,
3
3
  type RuntimeHotUpdaterAPI,
4
4
  } from "../createHotUpdaterCore";
5
+ import type { AnyHotUpdaterPlugin } from "../plugins/definePlugin";
5
6
  import { type Migrator, type SchemaGenerator } from "./types";
6
7
 
7
8
  export { createDatabaseCoreApi, type CoreApi } from "../core/api";
@@ -11,8 +12,19 @@ export {
11
12
  type MeasuredDatabase,
12
13
  type MeasuredDatabaseOptions,
13
14
  } from "../assembly/databasePlugins";
15
+ export {
16
+ clientAuthOf,
17
+ clientPluginsOf,
18
+ generateClientCredential,
19
+ pluginCommandsOf,
20
+ provisionClientCredential,
21
+ type ClientAuthSpec,
22
+ type ClientCredentialSpec,
23
+ type ClientPluginSpec,
24
+ type PluginCommandEntry,
25
+ type ProvisionedClientCredential,
26
+ } from "../assembly/pluginCli";
14
27
  export { targetBaseCandidateKey } from "../core/baseCandidates";
15
- export * from "./createBundleDiff";
16
28
  export {
17
29
  generateEngineSql,
18
30
  settingsStatements,
@@ -44,11 +56,16 @@ const getDBMetadata = (hotUpdater: HotUpdaterDBTarget) => {
44
56
  return metadata;
45
57
  };
46
58
 
59
+ /** A server's plugins as configured, whose commands the CLI adds. */
60
+ export const serverPluginsOf = (
61
+ hotUpdater: HotUpdaterDBTarget,
62
+ ): readonly AnyHotUpdaterPlugin[] => getDBMetadata(hotUpdater).plugins;
63
+
47
64
  /** Whether `hot-updater db generate` writes schema files for a server's database. */
48
65
  export const generatesSchema = (hotUpdater: HotUpdaterDBTarget): boolean =>
49
66
  getDBMetadata(hotUpdater).database.generateSchema !== undefined;
50
67
 
51
- /** The migrator for a server's database: its built-in tables and its plugins' tables. */
68
+ /** The migrator for a server's database: core's tables and its plugins' tables. */
52
69
  export function createMigrator(hotUpdater: HotUpdaterDBTarget): Migrator {
53
70
  const { database, target } = getDBMetadata(hotUpdater);
54
71
  if (database.createMigrator === undefined) {
@@ -10,13 +10,18 @@ import { prismaAdapter } from "../adapters/prisma";
10
10
  import { createHotUpdater } from "../createHotUpdaterCore";
11
11
  import { HotUpdaterSchemaMigrationRequiredError } from "../database/fence";
12
12
  import { defineTable } from "../database/schema";
13
- import { definePlugin } from "../plugins/definePlugin";
13
+ import { createTableStatements } from "../database/sql/sqlAdapter";
14
+ import { apiKeys } from "../plugins/api-keys";
15
+ import {
16
+ type AnyHotUpdaterPlugin,
17
+ definePlugin,
18
+ } from "../plugins/definePlugin";
14
19
  import { insights } from "../plugins/insights";
15
20
  import {
16
- builtInTarget,
21
+ coreTarget,
17
22
  createEngineDatabase,
18
23
  toolingTargetOf,
19
- } from "./builtInDatabase";
24
+ } from "./coreDatabase";
20
25
  import { createMigrator, generateSchema } from "./index";
21
26
 
22
27
  const notes = definePlugin({
@@ -56,21 +61,33 @@ const migrate = async (hotUpdater: { readonly adapterName: string }) => {
56
61
  const memoryDatabase = () =>
57
62
  createEngineDatabase({ name: "memory", adapter: createMemoryAdapter() });
58
63
 
59
- describe("third-party plugin tables in db tooling", () => {
60
- it("adds each third-party plugin's namespaced tables and settings row to the built-in ones", () => {
61
- expect(toolingTargetOf([insights()])).toBe(builtInTarget);
64
+ describe("plugin tables in db tooling", () => {
65
+ it("adds each plugin's tables and settings row to core's, prefixed by its id unless it keeps its names", () => {
66
+ expect(toolingTargetOf([])).toBe(coreTarget);
62
67
  const target = toolingTargetOf([insights(), notes]);
63
- expect(target.schema.tables.map(({ name }) => name)).toEqual([
64
- ...builtInTarget.schema.tables.map(({ name }) => name),
65
- "notes_notes",
66
- ]);
68
+ const names = target.schema.tables.map(({ name }) => name);
69
+ expect(names.slice(0, coreTarget.schema.tables.length)).toEqual(
70
+ coreTarget.schema.tables.map(({ name }) => name),
71
+ );
72
+ expect(names).toContain("bundle_events");
73
+ expect(names.at(-1)).toBe("notes_notes");
67
74
  expect(target.settings).toEqual({
68
- ...builtInTarget.settings,
75
+ ...coreTarget.settings,
76
+ "schema.insights": insights().schemaVersion,
69
77
  "schema.notes": "2",
70
78
  });
71
79
  });
72
80
 
73
- it("migrates an engine database's built-in and plugin tables with its adapter", async () => {
81
+ it("fits Hot Updater's own plugins' tables within MySQL's key limit", () => {
82
+ expect(() =>
83
+ createTableStatements(
84
+ "mysql",
85
+ toolingTargetOf([insights(), apiKeys()]).schema.tables,
86
+ ),
87
+ ).not.toThrow();
88
+ });
89
+
90
+ it("migrates an engine database's core and plugin tables with its adapter", async () => {
74
91
  const hotUpdater = createHotUpdater({
75
92
  database: memoryDatabase(),
76
93
  plugins: [notes],
@@ -180,7 +197,7 @@ describe("third-party plugin tables in db tooling", () => {
180
197
  ).toContain("model notes_notes {");
181
198
  });
182
199
 
183
- it("rejects a third-party plugin that takes a built-in plugin's id or table", () => {
200
+ it("checks a plugin's id and tables against core and the other plugins the server runs", () => {
184
201
  const named = definePlugin({
185
202
  id: "insights",
186
203
  schemaVersion: "1",
@@ -197,22 +214,35 @@ describe("third-party plugin tables in db tooling", () => {
197
214
  },
198
215
  init: () => ({ api: {} }),
199
216
  });
200
-
201
- expect(() =>
202
- createHotUpdater({
203
- database: memoryDatabase(),
204
- plugins: [named],
205
- clientAccess: "public",
206
- }),
207
- ).toThrow(`Plugin "insights" uses a built-in plugin's id.`);
208
- expect(() =>
217
+ const unprefixed = definePlugin({
218
+ id: "shadowCore",
219
+ namespace: false,
220
+ schemaVersion: "1",
221
+ schema: {
222
+ channels: defineTable({ id: { type: "string" } }, { key: ["id"] }),
223
+ },
224
+ init: () => ({ api: {} }),
225
+ });
226
+ const start = (plugins: readonly AnyHotUpdaterPlugin[]) => () =>
209
227
  createHotUpdater({
210
228
  database: memoryDatabase(),
211
- plugins: [tabled],
212
- clientAccess: "public",
213
- }),
214
- ).toThrow(
215
- 'api.keys: table "api_keys" is also declared by apiKeys.api_keys',
229
+ plugins,
230
+ ...(plugins.some(({ provides }) => provides?.clientAuth)
231
+ ? {}
232
+ : { clientAccess: "public" }),
233
+ } as Parameters<typeof createHotUpdater>[0]);
234
+
235
+ // Only the plugins a server runs get tables, so these fit on their own.
236
+ expect(start([named])).not.toThrow();
237
+ expect(start([tabled])).not.toThrow();
238
+ expect(start([named, insights()])).toThrow(
239
+ 'Plugin "insights" is registered twice.',
240
+ );
241
+ expect(start([tabled, apiKeys()])).toThrow(
242
+ 'apiKeys.api_keys: table "api_keys" is also declared by api.keys',
243
+ );
244
+ expect(start([unprefixed])).toThrow(
245
+ 'shadowCore.channels: table "channels" is also declared by core.channels',
216
246
  );
217
247
  });
218
248
  });