stellarium-ts 0.19.1 → 0.20.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 (80) hide show
  1. package/dist/client/operatorClient.d.ts +34 -3
  2. package/dist/client/operatorClient.d.ts.map +1 -1
  3. package/dist/client/operatorClient.js +15 -0
  4. package/dist/contract/repository.d.ts +49 -0
  5. package/dist/contract/repository.d.ts.map +1 -0
  6. package/dist/contract/repository.js +80 -0
  7. package/dist/contract/service.d.ts +103 -0
  8. package/dist/contract/service.d.ts.map +1 -0
  9. package/dist/contract/service.js +297 -0
  10. package/dist/contract/state.d.ts +43 -0
  11. package/dist/contract/state.d.ts.map +1 -0
  12. package/dist/contract/state.js +79 -0
  13. package/dist/db/database.d.ts +25 -0
  14. package/dist/db/database.d.ts.map +1 -0
  15. package/dist/db/database.js +1 -0
  16. package/dist/db/db.d.ts +12 -4
  17. package/dist/db/db.d.ts.map +1 -1
  18. package/dist/db/db.js +96 -28
  19. package/dist/db/postgres.d.ts +39 -0
  20. package/dist/db/postgres.d.ts.map +1 -0
  21. package/dist/db/postgres.js +132 -0
  22. package/dist/db/sqlite.d.ts +16 -0
  23. package/dist/db/sqlite.d.ts.map +1 -0
  24. package/dist/db/sqlite.js +53 -0
  25. package/dist/grant/repository.d.ts +2 -2
  26. package/dist/grant/repository.d.ts.map +1 -1
  27. package/dist/grant/repository.js +2 -2
  28. package/dist/index.d.ts +3 -1
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +2 -1
  31. package/dist/peer/repository.d.ts +2 -2
  32. package/dist/peer/repository.d.ts.map +1 -1
  33. package/dist/peer/repository.js +1 -1
  34. package/dist/resource/invoker.d.ts +85 -0
  35. package/dist/resource/invoker.d.ts.map +1 -1
  36. package/dist/resource/invoker.js +69 -6
  37. package/dist/resource/repository.d.ts +51 -10
  38. package/dist/resource/repository.d.ts.map +1 -1
  39. package/dist/resource/repository.js +116 -61
  40. package/dist/resource/settlement.d.ts +5 -0
  41. package/dist/resource/settlement.d.ts.map +1 -1
  42. package/dist/resource/settlement.js +19 -0
  43. package/dist/routes.d.ts.map +1 -1
  44. package/dist/routes.js +161 -25
  45. package/dist/server.d.ts +29 -0
  46. package/dist/server.d.ts.map +1 -1
  47. package/dist/server.js +80 -6
  48. package/dist/service.d.ts +26 -3
  49. package/dist/service.d.ts.map +1 -1
  50. package/dist/service.js +16 -1
  51. package/dist/startup.d.ts +2 -2
  52. package/dist/startup.d.ts.map +1 -1
  53. package/dist/startup.js +43 -34
  54. package/dist/tests/contract/repository.test.d.ts +2 -0
  55. package/dist/tests/contract/repository.test.d.ts.map +1 -0
  56. package/dist/tests/contract/repository.test.js +68 -0
  57. package/dist/tests/contract/routes.test.d.ts +2 -0
  58. package/dist/tests/contract/routes.test.d.ts.map +1 -0
  59. package/dist/tests/contract/routes.test.js +252 -0
  60. package/dist/tests/contract/state-routes.test.d.ts +2 -0
  61. package/dist/tests/contract/state-routes.test.d.ts.map +1 -0
  62. package/dist/tests/contract/state-routes.test.js +158 -0
  63. package/dist/tests/contract/state.test.d.ts +2 -0
  64. package/dist/tests/contract/state.test.d.ts.map +1 -0
  65. package/dist/tests/contract/state.test.js +57 -0
  66. package/dist/tests/db/db.test.js +1 -1
  67. package/dist/tests/db/postgres.test.d.ts +2 -0
  68. package/dist/tests/db/postgres.test.d.ts.map +1 -0
  69. package/dist/tests/db/postgres.test.js +43 -0
  70. package/dist/tests/grant/service.test.js +3 -4
  71. package/dist/tests/helpers.d.ts +8 -0
  72. package/dist/tests/helpers.d.ts.map +1 -1
  73. package/dist/tests/helpers.js +37 -0
  74. package/dist/tests/integration/network.test.js +74 -4
  75. package/dist/tests/peer/repository.test.js +3 -4
  76. package/dist/tests/resource/repository.test.js +3 -4
  77. package/dist/tests/routes.test.js +10 -11
  78. package/dist/tests/server.test.js +22 -0
  79. package/dist/tests/startup.test.js +4 -5
  80. package/package.json +5 -2
@@ -1,4 +1,7 @@
1
1
  import { generateKeyPairSync } from "node:crypto";
2
+ import { PGlite } from "@electric-sql/pglite";
3
+ import { createTables, openDatabase } from "../db/db.js";
4
+ import { PostgresDatabase } from "../db/postgres.js";
2
5
  export function generateBase64KeyPair() {
3
6
  const { publicKey, privateKey } = generateKeyPairSync("ed25519", {
4
7
  publicKeyEncoding: { type: "spki", format: "der" },
@@ -82,3 +85,37 @@ export function modelResource(baseUrl = "http://127.0.0.1:3002") {
82
85
  };
83
86
  }
84
87
  export const temperatureSource = 'request GetTemperature {\n const weather: com.example.Weather\n\n return weather.temperature(city: "Oslo")\n}';
88
+ /** Both databases a node can use. Suites that touch the database run against each. */
89
+ export const DIALECTS = ["sqlite", "postgres"];
90
+ export function pgliteDriver(pg) {
91
+ return {
92
+ query: async (text, params) => {
93
+ const result = await pg.query(text, params);
94
+ return { rows: result.rows, rowCount: result.affectedRows ?? 0 };
95
+ },
96
+ exec: async (text) => {
97
+ await pg.exec(text);
98
+ },
99
+ transaction: (work) => {
100
+ if (!("transaction" in pg)) {
101
+ throw new Error("Transactions cannot be nested");
102
+ }
103
+ return pg.transaction(tx => work(pgliteDriver(tx)));
104
+ },
105
+ close: async () => {
106
+ if ("close" in pg) {
107
+ await pg.close();
108
+ }
109
+ },
110
+ };
111
+ }
112
+ /** An empty node database: SQLite in memory, or Postgres in memory through PGlite. */
113
+ export async function openTestDatabase(dialect = "sqlite") {
114
+ if (dialect === "sqlite") {
115
+ return openDatabase(":memory:");
116
+ }
117
+ // 64-bit integers come back as numbers, as node-postgres is configured to return them.
118
+ const db = await PostgresDatabase.open(pgliteDriver(new PGlite({ parsers: { 20: Number } })));
119
+ await createTables(db);
120
+ return db;
121
+ }
@@ -5,7 +5,7 @@ import path from "node:path";
5
5
  import Fastify from "fastify";
6
6
  import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
7
7
  import { verifyData } from "../../crypto/utils.js";
8
- import { paymentVoucherPayload } from "../../resource/invoker.js";
8
+ import { contractVoucherPayload, paymentVoucherPayload } from "../../resource/invoker.js";
9
9
  import { StellariumNode } from "../../server.js";
10
10
  import { COST, generateBase64KeyPair, INFERENCE_RATES, modelResource, ORGANIZATION, temperatureSource, weatherResource } from "../helpers.js";
11
11
  /** A free function only callers with a grant from the provider may run. */
@@ -22,6 +22,8 @@ function vaultResource(baseUrl) {
22
22
  const vaultSource = 'request OpenVault {\n const vault: com.example.Vault\n\n return vault.open(city: "Oslo")\n}';
23
23
  const vault = { resourcePath: "com.example", resourceName: "Vault" };
24
24
  const HOUR = 3_600_000;
25
+ const CONTRACT_PRICE = 1_000;
26
+ const forecastContract = 'contract com.example.Forecast(city: string): string {\n const weather: com.example.Weather\n const temperature = weather.temperature(city: city)\n return "${temperature.value} degrees"\n}';
25
27
  const completeSource = 'request Complete {\n const model: com.example.Model\n\n return model.complete(prompt: "Hello world!", maxTokens: 10)\n}';
26
28
  function freePort() {
27
29
  return new Promise((resolve, reject) => {
@@ -45,8 +47,12 @@ describe("two-node network", { timeout: 15_000 }, () => {
45
47
  const authorizations = [];
46
48
  const releases = [];
47
49
  const sessions = [];
50
+ const contractAuthorizations = [];
51
+ const runCaptures = [];
48
52
  /** What the fake operator answers to the next authorizations; undefined places the hold. */
49
53
  let refuseAuthorization;
54
+ /** The provider's price, as its organization set it on the dashboard. */
55
+ let price = CONTRACT_PRICE;
50
56
  const callerKeys = generateBase64KeyPair();
51
57
  let dir;
52
58
  let operatorUrl;
@@ -84,7 +90,10 @@ describe("two-node network", { timeout: 15_000 }, () => {
84
90
  authorizations.length = 0;
85
91
  releases.length = 0;
86
92
  sessions.length = 0;
93
+ contractAuthorizations.length = 0;
94
+ runCaptures.length = 0;
87
95
  refuseAuthorization = undefined;
96
+ price = CONTRACT_PRICE;
88
97
  const operator = Fastify();
89
98
  servers.push(operator);
90
99
  operator.post("/v1/nodes/session", async (request, reply) => {
@@ -92,7 +101,15 @@ describe("two-node network", { timeout: 15_000 }, () => {
92
101
  return reply.status(401).send({ error: "Unsigned request" });
93
102
  }
94
103
  sessions.push(request.body);
95
- return { organizationId: ORGANIZATION };
104
+ return { organizationId: ORGANIZATION, priceMicroUsdPerMillionTokens: price };
105
+ });
106
+ operator.post("/v1/runs/contract/authorize", async (request) => {
107
+ contractAuthorizations.push(request.body);
108
+ return { status: "held", heldMicroUsd: 50_000, maxTokens: 100, tokenBudgetMicroUsd: 1, payerOrganizationId: "org_caller" };
109
+ });
110
+ operator.post("/v1/runs/capture", async (request) => {
111
+ runCaptures.push(request.body);
112
+ return { status: "captured" };
96
113
  });
97
114
  operator.post("/v1/payments/authorize", async (request, reply) => {
98
115
  authorizations.push(request.body);
@@ -123,6 +140,7 @@ describe("two-node network", { timeout: 15_000 }, () => {
123
140
  operatorUrl,
124
141
  databasePath: ":memory:",
125
142
  apiKey: "provider-admin",
143
+ sessionRefreshIntervalMs: 50,
126
144
  approveGrant: ({ details }) => details?.invite === "letmein" ? { lifetimeMs: HOUR } : undefined,
127
145
  });
128
146
  });
@@ -154,6 +172,56 @@ describe("two-node network", { timeout: 15_000 }, () => {
154
172
  const second = await startCaller();
155
173
  expect(await run(second.url)).toEqual({ usage: { tokens: expect.any(Number) }, returnValue: { value: 12, error: null } });
156
174
  });
175
+ test("the provider's contract reaches the caller, which pays for it and for the paid call it makes", async () => {
176
+ const caller = await startCaller();
177
+ const deployed = await fetch(`${providerUrl}/v1/contracts`, {
178
+ method: "POST",
179
+ headers: { "Content-Type": "application/json", Authorization: "Bearer provider-admin" },
180
+ body: JSON.stringify({ source: forecastContract }),
181
+ });
182
+ expect(deployed.status).toBe(201);
183
+ // The provider announces its resources again when a contract is deployed.
184
+ await vi.waitFor(async () => expect((await getJson(`${caller.url}/v1/resources`)).resources).toContainEqual(expect.objectContaining({
185
+ name: "Forecast",
186
+ baseUrl: providerUrl,
187
+ functions: [expect.objectContaining({ name: "run", kind: "contract", paymentManifest: { priceMicroUsdPerMillionTokens: CONTRACT_PRICE, recipientOrganizationId: ORGANIZATION } })],
188
+ })));
189
+ const result = await run(caller.url, 'request R {\n const forecast: com.example.Forecast\n return forecast.run(city: "Oslo")\n}');
190
+ expect(result.returnValue).toEqual({ value: "12 degrees", error: null });
191
+ const [{ voucher, source }] = contractAuthorizations;
192
+ expect(source).toBe(forecastContract);
193
+ expect(voucher).toMatchObject({ kind: "contract", publicKey: callerKeys.publicKey, recipientOrganizationId: ORGANIZATION, priceMicroUsdPerMillionTokens: CONTRACT_PRICE });
194
+ expect(verifyData(contractVoucherPayload(voucher), voucher.publicKey, voucher.signature)).toBe(true);
195
+ // The contract's run pays for its tokens and for its call to the provider's paid function.
196
+ await vi.waitFor(() => expect(runCaptures).toEqual([{ runId: voucher.invocationId, tokens: expect.any(Number) }]));
197
+ await vi.waitFor(() => expect(settlements).toHaveLength(1));
198
+ expect(settlements[0]).toMatchObject({ amount: COST, recipientOrganizationId: ORGANIZATION, runId: voucher.invocationId });
199
+ });
200
+ test("a contract keeps state for the organization paying for a remote call", async () => {
201
+ const caller = await startCaller();
202
+ await fetch(`${providerUrl}/v1/contracts`, {
203
+ method: "POST",
204
+ headers: { "Content-Type": "application/json", Authorization: "Bearer provider-admin" },
205
+ body: JSON.stringify({ source: "contract com.example.Visits: object {\n var state(caller.org) visits: int32 = 0\n visits = visits + 1\n return { visits: visits, by: caller }\n}" }),
206
+ });
207
+ await vi.waitFor(async () => expect((await getJson(`${caller.url}/v1/resources`)).resources.map((resource) => resource.name)).toContain("Visits"));
208
+ const visit = 'request R {\n const visits: com.example.Visits\n return visits.run().value\n}';
209
+ expect((await run(caller.url, visit)).returnValue).toEqual({ visits: 1, by: { org: "org_caller", node: callerKeys.publicKey } });
210
+ expect((await run(caller.url, visit)).returnValue).toMatchObject({ visits: 2 });
211
+ });
212
+ test("the provider learns a new price from the operator, and tells peers its contracts' new price", async () => {
213
+ const caller = await startCaller();
214
+ await fetch(`${providerUrl}/v1/contracts`, {
215
+ method: "POST",
216
+ headers: { "Content-Type": "application/json", Authorization: "Bearer provider-admin" },
217
+ body: JSON.stringify({ source: forecastContract }),
218
+ });
219
+ price = 2 * CONTRACT_PRICE;
220
+ await vi.waitFor(async () => {
221
+ const forecast = (await getJson(`${caller.url}/v1/resources`)).resources.find((resource) => resource.name === "Forecast");
222
+ expect(forecast?.functions[0].paymentManifest.priceMicroUsdPerMillionTokens).toBe(2 * CONTRACT_PRICE);
223
+ });
224
+ });
157
225
  describe("restricted functions", () => {
158
226
  test("the caller needs a grant from the provider, then calls with it", async () => {
159
227
  const caller = await startCaller();
@@ -223,10 +291,12 @@ describe("two-node network", { timeout: 15_000 }, () => {
223
291
  test("a caller with an enrollment token enrolls at startup; one without still reports its URL", async () => {
224
292
  sessions.length = 0;
225
293
  const enrolled = await startCaller({ operatorUrl, enrollmentToken: "enr_caller" });
226
- expect(sessions).toEqual([{ enrollmentToken: "enr_caller", baseUrl: enrolled.url }]);
294
+ // The provider refreshes its own session meanwhile.
295
+ const callerSessions = () => sessions.filter(session => session.baseUrl !== providerUrl);
296
+ expect(callerSessions()).toEqual([{ enrollmentToken: "enr_caller", baseUrl: enrolled.url }]);
227
297
  sessions.length = 0;
228
298
  const other = await startCaller({ operatorUrl, databasePath: path.join(dir, "caller-2.db") });
229
- await vi.waitFor(() => expect(sessions).toEqual([{ baseUrl: other.url }]));
299
+ await vi.waitFor(() => expect(callerSessions()).toEqual([{ baseUrl: other.url }]));
230
300
  });
231
301
  test("a node without paid functions or a token starts without waiting for an unreachable operator", async () => {
232
302
  const caller = await startCaller({ operatorUrl: "http://127.0.0.1:1/v1", databasePath: path.join(dir, "caller-3.db") });
@@ -1,16 +1,15 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "vitest";
2
2
  import { signData } from "../../crypto/utils.js";
3
- import { openDatabase } from "../../db/db.js";
4
3
  import { PeerRepository, peerAnnouncementPayload } from "../../peer/repository.js";
5
- import { generateBase64KeyPair } from "../helpers.js";
6
- describe("PeerRepository", () => {
4
+ import { DIALECTS, generateBase64KeyPair, openTestDatabase } from "../helpers.js";
5
+ describe.each(DIALECTS)("PeerRepository (%s)", (dialect) => {
7
6
  const keys = generateBase64KeyPair();
8
7
  const peer = { baseUrl: "http://peer.example", name: "peer", publicKey: keys.publicKey };
9
8
  const expiry = () => new Date(Date.now() + 60_000).toISOString();
10
9
  let db;
11
10
  let repository;
12
11
  beforeEach(async () => {
13
- db = await openDatabase(":memory:");
12
+ db = await openTestDatabase(dialect);
14
13
  repository = new PeerRepository(db);
15
14
  });
16
15
  afterEach(async () => {
@@ -1,9 +1,8 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "vitest";
2
2
  import { signData } from "../../crypto/utils.js";
3
- import { openDatabase } from "../../db/db.js";
4
3
  import { PeerRepository } from "../../peer/repository.js";
5
4
  import { resourceAnnouncementPayload, ResourceRepository } from "../../resource/repository.js";
6
- import { generateBase64KeyPair, ORGANIZATION, weatherResource } from "../helpers.js";
5
+ import { DIALECTS, generateBase64KeyPair, ORGANIZATION, weatherResource, openTestDatabase } from "../helpers.js";
7
6
  const SELF = "http://self.example";
8
7
  const PROVIDER = "http://provider.example";
9
8
  function resource(name, functions, baseUrl = PROVIDER) {
@@ -16,11 +15,11 @@ function resource(name, functions, baseUrl = PROVIDER) {
16
15
  functions: functions.map(fn => ({ name: "fn", parameters: [], returnType: "string", exec: async () => undefined, ...fn })),
17
16
  };
18
17
  }
19
- describe("ResourceRepository", () => {
18
+ describe.each(DIALECTS)("ResourceRepository (%s)", (dialect) => {
20
19
  let db;
21
20
  let repository;
22
21
  beforeEach(async () => {
23
- db = await openDatabase(":memory:");
22
+ db = await openTestDatabase(dialect);
24
23
  repository = new ResourceRepository(db, [weatherResource(SELF)], SELF);
25
24
  });
26
25
  afterEach(async () => {
@@ -3,7 +3,6 @@ import Fastify from "fastify";
3
3
  import { createLocalJWKSet, exportJWK, SignJWT } from "jose";
4
4
  import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
5
5
  import { safeVerifyData, signData } from "../crypto/utils.js";
6
- import { openDatabase } from "../db/db.js";
7
6
  import { grantRenewalPayload, grantRequestPayload } from "../grant/grant.js";
8
7
  import { GrantRepository } from "../grant/repository.js";
9
8
  import { GrantService } from "../grant/service.js";
@@ -12,10 +11,10 @@ import { CombinationResourceInvoker, inferenceVoucherPayload, paymentVoucherPayl
12
11
  import { resourceAnnouncementPayload } from "../resource/repository.js";
13
12
  import routes from "../routes.js";
14
13
  import { createService } from "../service.js";
15
- import { COST, generateBase64KeyPair, INFERENCE_RATES, modelResource, ORGANIZATION, temperatureSource, testConfig, weatherResource } from "./helpers.js";
14
+ import { DIALECTS, COST, generateBase64KeyPair, INFERENCE_RATES, modelResource, ORGANIZATION, temperatureSource, testConfig, weatherResource, openTestDatabase } from "./helpers.js";
16
15
  const parameters = [{ name: "city", value: "Oslo" }];
17
16
  const inMinutes = (minutes) => new Date(Date.now() + minutes * 60_000).toISOString();
18
- describe("routes", () => {
17
+ describe.each(DIALECTS)("routes (%s)", (dialect) => {
19
18
  const weather = weatherResource();
20
19
  const caller = generateBase64KeyPair();
21
20
  // Signs vouchers as a remote caller would. Its local branch never touches the repository.
@@ -28,7 +27,7 @@ describe("routes", () => {
28
27
  let capture;
29
28
  let release;
30
29
  beforeEach(async () => {
31
- db = await openDatabase(":memory:");
30
+ db = await openTestDatabase(dialect);
32
31
  config = testConfig();
33
32
  service = createService(db, [weather], config);
34
33
  authorize = vi.spyOn(service.operatorClient, "authorize").mockResolvedValue({ ok: true });
@@ -490,7 +489,7 @@ describe("routes", () => {
490
489
  });
491
490
  });
492
491
  });
493
- describe("restricted functions", () => {
492
+ describe.each(DIALECTS)("restricted functions (%s)", (dialect) => {
494
493
  const caller = generateBase64KeyPair();
495
494
  const callerInvoker = new CombinationResourceInvoker(undefined, 100_000, caller.publicKey, caller.privateKey);
496
495
  const restricted = {
@@ -512,12 +511,12 @@ describe("restricted functions", () => {
512
511
  let fastify;
513
512
  let grant;
514
513
  beforeEach(async () => {
515
- db = await openDatabase(":memory:");
514
+ db = await openTestDatabase(dialect);
516
515
  service = createService(db, [restricted], testConfig());
517
516
  vi.spyOn(service.operatorClient, "authorize").mockResolvedValue({ ok: true });
518
517
  vi.spyOn(service.operatorClient, "capture").mockResolvedValue(undefined);
519
518
  vi.spyOn(service.operatorClient, "release").mockResolvedValue(undefined);
520
- callerDb = await openDatabase(":memory:");
519
+ callerDb = await openTestDatabase(dialect);
521
520
  callerGrants = new GrantService(new GrantRepository(callerDb), { baseUrl: "http://caller", ...caller });
522
521
  grant = await service.grantService.issue(caller.publicKey, [{ resourcePath: "com.example", resourceName: "Vault" }], { lifetimeMs: 3_600_000 });
523
522
  fastify = Fastify();
@@ -553,7 +552,7 @@ describe("restricted functions", () => {
553
552
  beforeEach(async () => {
554
553
  await fastify.close();
555
554
  await db.close();
556
- db = await openDatabase(":memory:");
555
+ db = await openTestDatabase(dialect);
557
556
  service = createService(db, [restricted], testConfig(), {
558
557
  approveGrant: ({ details }) => details?.invite === "letmein" ? { lifetimeMs: 3_600_000 } : undefined,
559
558
  });
@@ -587,7 +586,7 @@ describe("restricted functions", () => {
587
586
  test("answers 500 when the approval hook returns an invalid approval", async () => {
588
587
  await fastify.close();
589
588
  await db.close();
590
- db = await openDatabase(":memory:");
589
+ db = await openTestDatabase(dialect);
591
590
  service = createService(db, [restricted], testConfig(), { approveGrant: () => ({ lifetimeMs: -1 }) });
592
591
  fastify = Fastify();
593
592
  await fastify.register(routes, { prefix: "/v1", service });
@@ -700,7 +699,7 @@ describe("restricted functions", () => {
700
699
  });
701
700
  });
702
701
  });
703
- describe("inference functions", () => {
702
+ describe.each(DIALECTS)("inference functions (%s)", (dialect) => {
704
703
  const weather = weatherResource();
705
704
  const model = modelResource();
706
705
  const caller = generateBase64KeyPair();
@@ -716,7 +715,7 @@ describe("inference functions", () => {
716
715
  let capture;
717
716
  let release;
718
717
  beforeEach(async () => {
719
- db = await openDatabase(":memory:");
718
+ db = await openTestDatabase(dialect);
720
719
  service = createService(db, [weather, model], testConfig());
721
720
  authorize = vi.spyOn(service.operatorClient, "authorize").mockResolvedValue({ ok: true });
722
721
  capture = vi.spyOn(service.operatorClient, "capture").mockResolvedValue(undefined);
@@ -33,6 +33,28 @@ describe("resolveConfig", () => {
33
33
  expect(resolveConfig(keys).databasePath).toBe("/data/requestscript.db");
34
34
  expect(resolveConfig({ ...keys, databasePath: ":memory:" }).databasePath).toBe(":memory:");
35
35
  });
36
+ test("uses Postgres when DATABASE_URL is set, in DATABASE_SCHEMA", () => {
37
+ vi.stubEnv("DATABASE_PATH", undefined);
38
+ vi.stubEnv("DATABASE_URL", "postgresql://node@db/stellarium");
39
+ vi.stubEnv("DATABASE_SCHEMA", "node_a");
40
+ const config = resolveConfig(keys);
41
+ expect(config).toMatchObject({ databaseUrl: "postgresql://node@db/stellarium", databaseSchema: "node_a" });
42
+ expect(config.databasePath).toBeUndefined();
43
+ vi.stubEnv("DATABASE_URL", "");
44
+ vi.stubEnv("DATABASE_SCHEMA", "");
45
+ expect(resolveConfig(keys).databasePath).toBe(DEFAULT_DATABASE_PATH);
46
+ expect(resolveConfig(keys).databaseUrl).toBeUndefined();
47
+ });
48
+ test("refuses a database configuration it can't use", () => {
49
+ vi.stubEnv("DATABASE_PATH", undefined);
50
+ vi.stubEnv("DATABASE_URL", undefined);
51
+ vi.stubEnv("DATABASE_SCHEMA", undefined);
52
+ const url = "postgres://db/stellarium";
53
+ expect(() => resolveConfig({ ...keys, databaseUrl: "mysql://db/stellarium" })).toThrow("postgres://");
54
+ expect(() => resolveConfig({ ...keys, databaseUrl: url, databasePath: "./node.db" })).toThrow("not both");
55
+ expect(() => resolveConfig({ ...keys, databaseSchema: "node_a" })).toThrow("needs a Postgres");
56
+ expect(() => resolveConfig({ ...keys, databaseUrl: url, databaseSchema: "Node-A" })).toThrow("Invalid database schema");
57
+ });
36
58
  test("requires the signing keys and base URL", () => {
37
59
  vi.stubEnv("SIGNING_PRIVATE_KEY", undefined);
38
60
  expect(() => resolveConfig({ baseUrl: keys.baseUrl, publicKey: keys.publicKey })).toThrow("SIGNING_PRIVATE_KEY is required");
@@ -1,13 +1,12 @@
1
1
  import Fastify from "fastify";
2
2
  import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
3
- import { openDatabase } from "../db/db.js";
4
3
  import routes from "../routes.js";
5
4
  import { createService } from "../service.js";
6
5
  import { STARTUP_PAGE_SIZE, StartupService } from "../startup.js";
7
- import { testConfig, weatherResource } from "./helpers.js";
6
+ import { DIALECTS, testConfig, weatherResource, openTestDatabase } from "./helpers.js";
8
7
  const STARTING_PEER = "http://starting.example";
9
8
  const SELF = "http://self.example";
10
- describe("StartupService", () => {
9
+ describe.each(DIALECTS)("StartupService (%s)", (dialect) => {
11
10
  const peerCount = STARTUP_PAGE_SIZE * 2 + 5;
12
11
  const resourceCount = STARTUP_PAGE_SIZE + 1;
13
12
  let databases;
@@ -16,7 +15,7 @@ describe("StartupService", () => {
16
15
  let startup;
17
16
  let requestedUrls;
18
17
  beforeEach(async () => {
19
- const startingDb = await openDatabase(":memory:");
18
+ const startingDb = await openTestDatabase(dialect);
20
19
  const startingService = createService(startingDb, [weatherResource(STARTING_PEER)], testConfig({ baseUrl: STARTING_PEER }));
21
20
  for (let i = 0; i < peerCount; i++) {
22
21
  const baseUrl = `http://peer${i}.example`;
@@ -36,7 +35,7 @@ describe("StartupService", () => {
36
35
  const response = await startingNode.inject({ method: "GET", url: url.pathname + url.search });
37
36
  return new Response(response.body, { status: response.statusCode, headers: { "Content-Type": "application/json" } });
38
37
  });
39
- const db = await openDatabase(":memory:");
38
+ const db = await openTestDatabase(dialect);
40
39
  databases = [startingDb, db];
41
40
  const config = testConfig({ baseUrl: SELF });
42
41
  service = createService(db, [], config);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stellarium-ts",
3
- "version": "0.19.1",
3
+ "version": "0.20.0",
4
4
  "description": "A Stellarium Node in TypeScript.",
5
5
  "homepage": "https://github.com/qualletio/stellarium-ts#readme",
6
6
  "bugs": {
@@ -35,7 +35,9 @@
35
35
  "license": "ISC",
36
36
  "devDependencies": {
37
37
  "@biomejs/biome": "^2.5.15",
38
+ "@electric-sql/pglite": "^0.5.10",
38
39
  "@types/node": "^26.6.4",
40
+ "@types/pg": "^8.23.1",
39
41
  "turbo": "^2.11.7",
40
42
  "typescript": "^7.0.2",
41
43
  "vite": "^8.3.3",
@@ -46,7 +48,8 @@
46
48
  "dotenv": "^18.0.5",
47
49
  "fastify": "^5.12.5",
48
50
  "jose": "6.1.0",
49
- "requestscript": "1.3.0",
51
+ "pg": "^8.23.1",
52
+ "requestscript": "1.5.0",
50
53
  "sqlite": "^5.1.1",
51
54
  "sqlite3": "^6.0.1"
52
55
  },