@wtfalch/payments 0.2.0 → 0.4.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.
package/dist/vipps.js CHANGED
@@ -5,22 +5,47 @@ import { PaymentProviderError, WebhookVerificationError, } from './types.js';
5
5
  * REST APIs -- no Vipps SDK (there is no single official Node one). See
6
6
  * docs/adr/0003-plain-http-no-provider-sdks.md.
7
7
  *
8
- * Sources (fetched 2026-09-23, primary docs, no live calls):
8
+ * Sources (fetched 2026-09-23/24, primary docs, no live calls):
9
9
  * - https://developer.vippsmobilepay.com/api/epayment/ (create, capture, cancel, refund)
10
- * - https://developer.vippsmobilepay.com/api/recurring/ (agreements, charges)
10
+ * - https://developer.vippsmobilepay.com/api/recurring/ (agreements, charges, status enum)
11
11
  * - https://developer.vippsmobilepay.com/docs/APIs/webhooks-api/request-authentication/
12
+ * - https://developer.vippsmobilepay.com/docs/APIs/webhooks-api/events/ (event catalogue)
13
+ * - https://developer.vippsmobilepay.com/docs/knowledge-base/across-borders/ (Nordic markets)
14
+ * - https://developer.vippsmobilepay.com/docs/APIs/recurring-api/recurring-api-guide/
12
15
  *
13
- * Two things v1 deliberately leaves to the caller, recorded here and in the
14
- * ledger's Fog rather than guessed at:
15
- * - the OAuth access token (`POST /accesstoken/get`): this adapter takes
16
- * an already-valid `accessToken` and never fetches or refreshes one
17
- * itself, the same "caller resolves the credential" shape as `stripe.ts`.
18
- * - the exact `state` enum on a payment/capture/refund response: the
19
- * fetched docs were not fully explicit about it, so `derivePaymentStatus`
20
- * below falls back to the `aggregate.*Amount` fields, which the docs did
21
- * confirm, whenever `state` is absent or unrecognised.
16
+ * Re-fetched 2026-09-28 (still primary docs, no live calls) for
17
+ * `stopRecurringAgreement`/`updateRecurringAgreement` (`PATCH
18
+ * /recurring/v3/agreements/{id}`), `cancelRecurringCharge` (`DELETE
19
+ * /recurring/v3/agreements/{id}/charges/{chargeId}`) and `cancelPayment`
20
+ * (`POST /epayment/v1/payments/{reference}/cancel`) -- same two URLs above.
21
+ *
22
+ * One Recurring/ePayment API serves all three Nordic markets (see
23
+ * docs/adr/0006-payment-store.md's "MobilePay" section and this package's
24
+ * README, "MobilePay (Denmark, Finland)"): same base URL, same headers, same
25
+ * request/response shapes. What differs is the sales unit's own currency
26
+ * (NOK/DKK/EUR) and per-market amount ceiling, both the caller's concern
27
+ * (`Money.currency`, `CreateRecurringAgreementInput.amount`), never this
28
+ * adapter's -- `createVippsProvider` needs no market-specific option.
29
+ *
30
+ * `docs/adr/0007-vipps-access-token-caching.md` covers this adapter now
31
+ * fetching and caching its own OAuth access token (`POST /accesstoken/get`),
32
+ * superseding v1's "the caller resolves it" stance (see that ADR for why
33
+ * this does not force the service shape -- docs/adr/0001).
34
+ *
35
+ * Still not confirmed from primary docs: the exact `state` enum on an
36
+ * ePayment payment/capture/refund response (`derivePaymentStatus` below
37
+ * falls back to the `aggregate.*Amount` fields whenever `state` is absent
38
+ * or unrecognised), and the exact field carrying a Recurring webhook
39
+ * event's own id (`normalizeRecurringEvent` below synthesizes one the same
40
+ * way the ePayment side already did, for the same reason).
22
41
  */
23
42
  import { hmacSha256Base64, safeEqual, sha256Base64 } from './webhook-crypto.js';
43
+ const AGREEMENT_STATUS = {
44
+ PENDING: 'pending',
45
+ ACTIVE: 'active',
46
+ STOPPED: 'stopped',
47
+ EXPIRED: 'expired',
48
+ };
24
49
  const PAYMENT_STATE = {
25
50
  CREATED: 'pending',
26
51
  AUTHORIZED: 'authorized',
@@ -80,14 +105,80 @@ function tomorrowIsoDate() {
80
105
  return d.toISOString().slice(0, 10);
81
106
  }
82
107
  export function createVippsProvider(options) {
108
+ if (!options.accessToken && !(options.clientId && options.clientSecret)) {
109
+ throw new PaymentProviderError('vipps', 'createVippsProvider needs either accessToken, or both clientId and clientSecret');
110
+ }
83
111
  const fetchImpl = options.fetch ?? defaultFetch();
84
112
  const baseUrl = options.baseUrl ?? 'https://api.vipps.no';
85
- function headers(idempotencyKey) {
113
+ const tokenUrl = options.tokenUrl ?? 'https://api.vipps.no/accesstoken/get';
114
+ const timeoutMs = options.timeoutMs ?? 10_000;
115
+ const refreshMarginMs = (options.tokenRefreshMarginSeconds ?? 60) * 1000;
116
+ // Token cache, scoped to this provider instance (one per `subscriptionKey`
117
+ // + `merchantSerialNumber` pair, ordinarily). `inFlight` is the one thing
118
+ // that makes this concurrency-safe: N callers racing `resolveAccessToken`
119
+ // while the cache is empty or expired share the same `POST
120
+ // /accesstoken/get` promise instead of each firing their own -- assigned
121
+ // before any `await`, so there is no gap between "check the cache" and
122
+ // "start a refresh" for a second caller to land in. Cleared in `finally`,
123
+ // on success and on failure alike: a failed refresh must not wedge every
124
+ // later call behind a promise that has already rejected.
125
+ let cached = null;
126
+ let inFlight = null;
127
+ async function fetchAccessToken() {
128
+ const response = await fetchImpl(tokenUrl, {
129
+ method: 'POST',
130
+ headers: {
131
+ client_id: options.clientId,
132
+ client_secret: options.clientSecret,
133
+ 'Ocp-Apim-Subscription-Key': options.subscriptionKey,
134
+ 'Merchant-Serial-Number': options.merchantSerialNumber,
135
+ },
136
+ signal: AbortSignal.timeout(timeoutMs),
137
+ });
138
+ const parsed = await response.json();
139
+ if (!response.ok) {
140
+ throw new PaymentProviderError('vipps', `access token request failed: HTTP ${response.status}`, {
141
+ status: response.status,
142
+ raw: parsed,
143
+ });
144
+ }
145
+ const body = parsed;
146
+ if (!body.access_token) {
147
+ throw new PaymentProviderError('vipps', 'access token response has no "access_token" field', {
148
+ raw: parsed,
149
+ });
150
+ }
151
+ const expiresInSeconds = Number(body.expires_in ?? 0);
152
+ cached = {
153
+ token: body.access_token,
154
+ // A missing/unparsable expires_in caches nothing usable: expiresAtMs
155
+ // stays in the past, so the very next call refreshes again rather
156
+ // than reusing a token whose real lifetime is unknown.
157
+ expiresAtMs: Number.isFinite(expiresInSeconds) && expiresInSeconds > 0
158
+ ? Date.now() + expiresInSeconds * 1000
159
+ : 0,
160
+ };
161
+ return cached.token;
162
+ }
163
+ async function resolveAccessToken() {
164
+ if (options.accessToken)
165
+ return options.accessToken; // v1 shape: caller-managed, never cached/refreshed here
166
+ if (cached && cached.expiresAtMs - refreshMarginMs > Date.now())
167
+ return cached.token;
168
+ if (!inFlight) {
169
+ inFlight = fetchAccessToken().finally(() => {
170
+ inFlight = null;
171
+ });
172
+ }
173
+ return inFlight;
174
+ }
175
+ async function resolveHeaders(idempotencyKey) {
176
+ const token = await resolveAccessToken();
86
177
  const h = {
87
178
  'Content-Type': 'application/json',
88
179
  'Ocp-Apim-Subscription-Key': options.subscriptionKey,
89
180
  'Merchant-Serial-Number': options.merchantSerialNumber,
90
- Authorization: `Bearer ${options.accessToken}`,
181
+ Authorization: `Bearer ${token}`,
91
182
  };
92
183
  if (idempotencyKey)
93
184
  h['Idempotency-Key'] = idempotencyKey;
@@ -96,8 +187,9 @@ export function createVippsProvider(options) {
96
187
  async function post(path, body, idempotencyKey) {
97
188
  const response = await fetchImpl(`${baseUrl}${path}`, {
98
189
  method: 'POST',
99
- headers: headers(idempotencyKey),
190
+ headers: await resolveHeaders(idempotencyKey),
100
191
  body: JSON.stringify(body),
192
+ signal: AbortSignal.timeout(timeoutMs),
101
193
  });
102
194
  const parsed = await response.json();
103
195
  if (!response.ok) {
@@ -111,7 +203,8 @@ export function createVippsProvider(options) {
111
203
  async function get(path) {
112
204
  const response = await fetchImpl(`${baseUrl}${path}`, {
113
205
  method: 'GET',
114
- headers: headers(undefined),
206
+ headers: await resolveHeaders(undefined),
207
+ signal: AbortSignal.timeout(timeoutMs),
115
208
  });
116
209
  const parsed = await response.json();
117
210
  if (!response.ok) {
@@ -122,6 +215,68 @@ export function createVippsProvider(options) {
122
215
  }
123
216
  return parsed;
124
217
  }
218
+ /** Reads any error body a non-2xx response carries, but never throws on a
219
+ * body that fails to parse (used by `patch`/`del` below, whose *success*
220
+ * responses are documented as empty -- see each call site). */
221
+ async function readErrorBody(response) {
222
+ try {
223
+ return await response.json();
224
+ }
225
+ catch {
226
+ return undefined;
227
+ }
228
+ }
229
+ /** `PATCH /recurring/v3/agreements/{id}` (`stopRecurringAgreement`,
230
+ * `updateRecurringAgreement`) documents a `204 No Content` success
231
+ * response -- https://developer.vippsmobilepay.com/api/recurring/
232
+ * (fetched 2026-09-28) -- so this never calls `response.json()` on the
233
+ * success path, unlike `post`/`get` above. */
234
+ async function patch(path, body, idempotencyKey) {
235
+ const response = await fetchImpl(`${baseUrl}${path}`, {
236
+ method: 'PATCH',
237
+ headers: await resolveHeaders(idempotencyKey),
238
+ body: JSON.stringify(body),
239
+ signal: AbortSignal.timeout(timeoutMs),
240
+ });
241
+ if (!response.ok) {
242
+ throw new PaymentProviderError('vipps', `HTTP ${response.status}`, {
243
+ status: response.status,
244
+ raw: await readErrorBody(response),
245
+ });
246
+ }
247
+ }
248
+ /** `DELETE /recurring/v3/agreements/{id}/charges/{chargeId}`
249
+ * (`cancelRecurringCharge`) documents a `202 Accepted`/`204 No Content`
250
+ * success response with no body -- same source and fetch date as `patch`
251
+ * above. */
252
+ async function del(path, idempotencyKey) {
253
+ const response = await fetchImpl(`${baseUrl}${path}`, {
254
+ method: 'DELETE',
255
+ headers: await resolveHeaders(idempotencyKey),
256
+ signal: AbortSignal.timeout(timeoutMs),
257
+ });
258
+ if (!response.ok) {
259
+ throw new PaymentProviderError('vipps', `HTTP ${response.status}`, {
260
+ status: response.status,
261
+ raw: await readErrorBody(response),
262
+ });
263
+ }
264
+ }
265
+ /** Shared by `getRecurringAgreement` and the two `PATCH .../agreements`
266
+ * calls (`stopRecurringAgreement`, `updateRecurringAgreement`), neither of
267
+ * which gets its resulting status back in its own (empty) response body --
268
+ * both re-read the agreement the same way `getRecurringAgreement` always
269
+ * has. */
270
+ async function fetchAgreement(agreementReference) {
271
+ const agreement = await get(`/recurring/v3/agreements/${agreementReference}`);
272
+ return {
273
+ provider: 'vipps',
274
+ agreementReference: agreement.id,
275
+ status: (agreement.status ? AGREEMENT_STATUS[agreement.status] : undefined) ?? 'pending',
276
+ confirmationUrl: agreement.vippsConfirmationUrl,
277
+ raw: agreement,
278
+ };
279
+ }
125
280
  return {
126
281
  name: 'vipps',
127
282
  async createPayment(input) {
@@ -186,6 +341,39 @@ export function createVippsProvider(options) {
186
341
  raw: payment,
187
342
  };
188
343
  },
344
+ async cancelPayment(providerReference, input = {}) {
345
+ // POST /epayment/v1/payments/{reference}/cancel --
346
+ // https://developer.vippsmobilepay.com/api/epayment/ (fetched
347
+ // 2026-09-28). Cancels a reserved, uncaptured payment; Vipps does not
348
+ // document an Idempotency-Key requirement here (unlike capture/refund),
349
+ // but this adapter still sends one, consistent with every other
350
+ // mutating call in this package.
351
+ const payment = await post(`/epayment/v1/payments/${providerReference}/cancel`, {}, input.idempotencyKey ?? `cnc:${providerReference}`);
352
+ // Same integrity check as capturePayment/refundPayment.
353
+ if (payment.reference !== providerReference) {
354
+ throw new PaymentProviderError('vipps', `cancel response reference "${payment.reference}" does not match the requested payment "${providerReference}"`, { raw: payment });
355
+ }
356
+ // The cancel response does not always carry an amount (neither
357
+ // `amount` nor `aggregate.authorizedAmount`) -- when it does not,
358
+ // re-read the payment rather than assume Vipps left it as-is. If the
359
+ // re-read also has no amount, a caller must never read a made-up
360
+ // amount as if Vipps reported it: a defaulted 0/NOK is simply wrong
361
+ // for a EUR or DKK merchant. Throw instead, with a distinct `code` so
362
+ // a caller can tell "cancelled, amount unknown, re-read later" apart
363
+ // from a plain cancel failure.
364
+ const resolved = (payment.amount ?? payment.aggregate?.authorizedAmount)
365
+ ? payment
366
+ : await get(`/epayment/v1/payments/${providerReference}`);
367
+ const knownAmount = resolved.amount ?? resolved.aggregate?.authorizedAmount;
368
+ if (!knownAmount) {
369
+ throw new PaymentProviderError('vipps', `cancel response for payment ${providerReference} carries no amount to report, even after re-reading the payment`, { raw: resolved, code: 'cancel_amount_unknown' });
370
+ }
371
+ const fallback = {
372
+ value: knownAmount.value,
373
+ currency: knownAmount.currency.toUpperCase(),
374
+ };
375
+ return toPaymentResult(resolved, fallback);
376
+ },
189
377
  async createRecurringAgreement(input) {
190
378
  // pricing.type LEGACY (fixed amount) vs VARIABLE (suggestedMaxAmount,
191
379
  // no fixed amount): https://developer.vippsmobilepay.com/api/recurring/
@@ -247,6 +435,74 @@ export function createVippsProvider(options) {
247
435
  raw: charge,
248
436
  };
249
437
  },
438
+ async getRecurringAgreement(agreementReference) {
439
+ return fetchAgreement(agreementReference);
440
+ },
441
+ async stopRecurringAgreement(agreementReference, input = {}) {
442
+ // PATCH /recurring/v3/agreements/{id}, { status: 'STOPPED' } --
443
+ // https://developer.vippsmobilepay.com/api/recurring/ (fetched
444
+ // 2026-09-28). Idempotent on Vipps' side: stopping an already-STOPPED
445
+ // agreement returns 204 with no further effect. The response has no
446
+ // body, so this reads the resulting status back the same way
447
+ // `getRecurringAgreement` always has.
448
+ await patch(`/recurring/v3/agreements/${agreementReference}`, { status: 'STOPPED' }, input.idempotencyKey ?? `stop:${agreementReference}`);
449
+ return fetchAgreement(agreementReference);
450
+ },
451
+ async updateRecurringAgreement(agreementReference, input) {
452
+ // Same PATCH endpoint as stopRecurringAgreement. The update body's
453
+ // `pricing` is `UpdateAgreementPricingRequest` in Vipps' own Recurring
454
+ // v3 OpenAPI spec (recurring-swagger-id.yaml, "Recurring Payments
455
+ // Merchant API" 3.2.3, fetched 2026-09-29 from
456
+ // https://developer.vippsmobilepay.com/redocusaurus/recurring-swagger-id.yaml,
457
+ // linked from https://developer.vippsmobilepay.com/api/recurring/):
458
+ //
459
+ // PricingUpdateRequest:
460
+ // title: UpdateAgreementPricingRequest
461
+ // type: object
462
+ // properties:
463
+ // amount: { type: integer, ... }
464
+ // suggestedMaxAmount: { type: integer, ... }
465
+ //
466
+ // No `currency`, no `type` -- unlike `PricingRequestV3` (create), which
467
+ // requires both. An agreement's currency is fixed at creation and
468
+ // cannot be changed through this endpoint, so this checks the
469
+ // agreement's current currency (from a GET, `pricing.currency` on
470
+ // `AgreementResponseV3`) before sending anything, and refuses a
471
+ // currency change with a clear `PaymentProviderError` instead of
472
+ // sending a field Vipps' spec does not list and getting back a 400.
473
+ // This is the LEGACY (fixed-price) `amount` field; a VARIABLE
474
+ // agreement's `suggestedMaxAmount` cap is a separate, payer-driven
475
+ // value this method does not touch -- see
476
+ // `UpdateRecurringAgreementInput`'s doc comment. Vipps itself refuses
477
+ // (400) a price change on a stopped agreement; this adapter does not
478
+ // duplicate that check. It does refuse anything but a LEGACY
479
+ // agreement below, because Vipps applies `pricing.amount` only to a
480
+ // LEGACY one -- a PATCH against a VARIABLE or FLEXIBLE agreement
481
+ // returns 204 and changes nothing, which would otherwise read to this
482
+ // method's caller as a silent no-op success.
483
+ const nextCurrency = input.amount.currency.toUpperCase();
484
+ const current = await get(`/recurring/v3/agreements/${agreementReference}`);
485
+ if (current.pricing?.type !== 'LEGACY') {
486
+ throw new PaymentProviderError('vipps', `agreement ${agreementReference} has pricing.type "${current.pricing?.type ?? 'unknown'}"; updateRecurringAgreement's amount can only be updated on a LEGACY agreement`, { raw: current });
487
+ }
488
+ const currentCurrency = current.pricing?.currency?.toUpperCase();
489
+ if (currentCurrency && currentCurrency !== nextCurrency) {
490
+ throw new PaymentProviderError('vipps', `agreement ${agreementReference} is priced in ${currentCurrency}; updateRecurringAgreement cannot change its currency to ${nextCurrency} (Vipps’ update-agreement pricing body takes amount only)`, { raw: current });
491
+ }
492
+ await patch(`/recurring/v3/agreements/${agreementReference}`, { pricing: { amount: input.amount.value } }, input.idempotencyKey ?? `upd:${agreementReference}:${input.amount.value}:${nextCurrency}`);
493
+ return fetchAgreement(agreementReference);
494
+ },
495
+ async cancelRecurringCharge(agreementReference, chargeReference, input = {}) {
496
+ // DELETE /recurring/v3/agreements/{agreementId}/charges/{chargeId} --
497
+ // https://developer.vippsmobilepay.com/api/recurring/ (fetched
498
+ // 2026-09-28). Permitted for a PENDING/DUE/RESERVED charge; a
499
+ // PARTIALLY_CAPTURED charge has its remaining funds released back to
500
+ // the payer. Returns 202/204 with no body -- the caller learns the
501
+ // outcome via the resulting `recurring.charge-canceled.v1` webhook
502
+ // (`charge.canceled`, already wired to `applyWebhookEvent`), the same
503
+ // way a created charge's own outcome already arrives.
504
+ await del(`/recurring/v3/agreements/${agreementReference}/charges/${chargeReference}`, input.idempotencyKey ?? `dch:${chargeReference}`);
505
+ },
250
506
  };
251
507
  }
252
508
  const WEBHOOK_EVENT_NAME = {
@@ -259,6 +515,42 @@ const WEBHOOK_EVENT_NAME = {
259
515
  EXPIRED: 'payment.expired',
260
516
  ABORTED: 'payment.cancelled',
261
517
  };
518
+ /** The Recurring API's own webhook event catalogue -- a different namespace
519
+ * from the ePayment `name` field `WEBHOOK_EVENT_NAME` above maps.
520
+ * https://developer.vippsmobilepay.com/docs/APIs/webhooks-api/events/
521
+ * (fetched 2026-09-24 via a documentation-summarizing tool, the same
522
+ * caveat docs/adr/0004 already carries for the ePayment side -- not
523
+ * checked against one real signed Vipps delivery). */
524
+ const RECURRING_EVENT_TYPE = {
525
+ 'recurring.agreement-activated.v1': 'agreement.activated',
526
+ 'recurring.agreement-rejected.v1': 'agreement.rejected',
527
+ 'recurring.agreement-stopped.v1': 'agreement.stopped',
528
+ 'recurring.agreement-expired.v1': 'agreement.expired',
529
+ 'recurring.charge-reserved.v1': 'charge.reserved',
530
+ 'recurring.charge-captured.v1': 'charge.captured',
531
+ 'recurring.charge-canceled.v1': 'charge.canceled',
532
+ 'recurring.charge-refunded.v1': 'charge.refunded',
533
+ 'recurring.charge-failed.v1': 'charge.failed',
534
+ 'recurring.charge-creation-failed.v1': 'charge.failed',
535
+ };
536
+ /** A Recurring event names one agreement always, and a charge only for a
537
+ * `charge.*` type -- `paymentReference` (which `store.ts` matches against
538
+ * `payment_charges.provider_charge_id`) is empty for an `agreement.*` event,
539
+ * which has none. No documented event id field (unlike the ePayment side's
540
+ * `idempotencyKey`), so `eventId` is synthesized from what is documented,
541
+ * the same fallback shape the ePayment path below already uses. */
542
+ function normalizeRecurringEvent(event) {
543
+ const amount = event.amountCaptured ?? event.amountCanceled ?? event.amountRefunded ?? event.amount;
544
+ return {
545
+ provider: 'vipps',
546
+ type: RECURRING_EVENT_TYPE[event.eventType] ?? 'unknown',
547
+ eventId: `${event.agreementId ?? 'unknown'}:${event.chargeId ?? ''}:${event.eventType}:${event.occurred ?? ''}`,
548
+ paymentReference: event.chargeId ?? '',
549
+ agreementReference: event.agreementId,
550
+ amount: amount ? { value: amount.value, currency: amount.currency.toUpperCase() } : undefined,
551
+ raw: event,
552
+ };
553
+ }
262
554
  /**
263
555
  * Verifies a Vipps MobilePay webhook request, by hand, against the
264
556
  * Azure-API-Management-style HMAC scheme documented at
@@ -314,6 +606,12 @@ export function verifyVippsWebhook(rawBody, requestHeaders, webhookSecret, metho
314
606
  catch {
315
607
  throw new WebhookVerificationError('vipps', 'payload is not valid JSON');
316
608
  }
609
+ // Two payload shapes share this endpoint's signature scheme: ePayment's
610
+ // (`name`/`success`, handled below) and the Recurring API's (`eventType`,
611
+ // agreement/charge events -- see normalizeRecurringEvent's doc comment).
612
+ if (event.eventType) {
613
+ return normalizeRecurringEvent(event);
614
+ }
317
615
  const mapped = event.name ? WEBHOOK_EVENT_NAME[event.name] : undefined;
318
616
  const type = event.success === false ? 'payment.failed' : (mapped ?? 'unknown');
319
617
  return {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@wtfalch/payments",
3
- "version": "0.2.0",
4
- "description": "One PaymentProvider interface -- create a payment, capture, refund, set up and charge a recurring agreement, verify a webhook -- over Stripe and Vipps MobilePay. Holds no card data.",
3
+ "version": "0.4.0",
4
+ "description": "One PaymentProvider interface -- create a payment, capture, refund, set up and charge a recurring agreement, verify a webhook -- over Stripe and Vipps MobilePay, plus a provider-neutral Postgres store for agreements and charges. Holds no card data.",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "https://github.com/wtfalch/payments",
@@ -9,16 +9,13 @@
9
9
  },
10
10
  "license": "MIT",
11
11
  "type": "module",
12
- "files": [
13
- "dist",
14
- "README.md",
15
- "LICENSE"
16
- ],
12
+ "files": ["dist", "README.md", "LICENSE"],
17
13
  "exports": {
18
14
  ".": {
19
15
  "types": "./dist/index.d.ts",
20
16
  "default": "./dist/index.js"
21
17
  },
18
+ "./migrations/*.sql": "./dist/migrations/*.sql",
22
19
  "./package.json": "./package.json"
23
20
  },
24
21
  "sideEffects": false,
@@ -28,14 +25,17 @@
28
25
  "engines": {
29
26
  "node": ">=22.0.0"
30
27
  },
28
+ "scripts": {
29
+ "build": "tsc -p tsconfig.build.json && node scripts/copy-migrations.mjs",
30
+ "typecheck": "tsc --noEmit",
31
+ "test": "vitest run",
32
+ "prepack": "pnpm build"
33
+ },
31
34
  "devDependencies": {
35
+ "@electric-sql/pglite": "^0.5.8",
32
36
  "@types/node": "^22",
37
+ "postgres": "^3.4.5",
33
38
  "typescript": "^5.9.0",
34
39
  "vitest": "^4.1.6"
35
- },
36
- "scripts": {
37
- "build": "tsc -p tsconfig.build.json",
38
- "typecheck": "tsc --noEmit",
39
- "test": "vitest run"
40
40
  }
41
- }
41
+ }