@zveltio/sdk 2.0.8 → 3.0.0-beta.2

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 (64) hide show
  1. package/dist/build/index.d.ts +77 -0
  2. package/dist/build/index.d.ts.map +1 -0
  3. package/dist/build/index.js +157 -0
  4. package/dist/client/QueryBuilder.d.ts.map +1 -1
  5. package/dist/client/QueryBuilder.js +12 -3
  6. package/dist/client/RealtimeClient.d.ts.map +1 -1
  7. package/dist/client/RealtimeClient.js +3 -1
  8. package/dist/client/ZveltioClient.d.ts.map +1 -1
  9. package/dist/client.d.ts.map +1 -1
  10. package/dist/client.js +16 -5
  11. package/dist/core.d.ts.map +1 -1
  12. package/dist/ddl/index.d.ts +153 -0
  13. package/dist/ddl/index.d.ts.map +1 -0
  14. package/dist/ddl/index.js +33 -0
  15. package/dist/extension/index.d.ts +522 -10
  16. package/dist/extension/index.d.ts.map +1 -1
  17. package/dist/extension/index.js +1 -1
  18. package/dist/extension/permission-gate.d.ts +37 -0
  19. package/dist/extension/permission-gate.d.ts.map +1 -0
  20. package/dist/extension/permission-gate.js +93 -0
  21. package/dist/generate-types.d.ts +37 -0
  22. package/dist/generate-types.d.ts.map +1 -0
  23. package/dist/generate-types.js +157 -0
  24. package/dist/index.d.ts +1 -1
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/local-store.d.ts.map +1 -1
  27. package/dist/local-store.js +1 -3
  28. package/dist/offline/index.d.ts +105 -0
  29. package/dist/offline/index.d.ts.map +1 -0
  30. package/dist/offline/index.js +235 -0
  31. package/dist/publish/index.d.ts +94 -0
  32. package/dist/publish/index.d.ts.map +1 -0
  33. package/dist/publish/index.js +148 -0
  34. package/dist/realtime.d.ts.map +1 -1
  35. package/dist/realtime.js +6 -2
  36. package/dist/rpc/index.d.ts +68 -0
  37. package/dist/rpc/index.d.ts.map +1 -0
  38. package/dist/rpc/index.js +61 -0
  39. package/dist/schema-codegen.d.ts +60 -0
  40. package/dist/schema-codegen.d.ts.map +1 -0
  41. package/dist/schema-codegen.js +369 -0
  42. package/dist/schema-watcher.d.ts.map +1 -1
  43. package/dist/schema-watcher.js +1 -3
  44. package/dist/studio/index.d.ts +84 -0
  45. package/dist/studio/index.d.ts.map +1 -0
  46. package/dist/studio/index.js +180 -0
  47. package/dist/svelte.d.ts.map +1 -1
  48. package/dist/sync-manager.d.ts.map +1 -1
  49. package/dist/sync-manager.js +4 -11
  50. package/dist/testing/index.d.ts +121 -0
  51. package/dist/testing/index.d.ts.map +1 -0
  52. package/dist/testing/index.js +283 -0
  53. package/dist/testing/with-test-db.d.ts +119 -0
  54. package/dist/testing/with-test-db.d.ts.map +1 -0
  55. package/dist/testing/with-test-db.js +313 -0
  56. package/dist/utils.d.ts.map +1 -1
  57. package/dist/utils.js +3 -1
  58. package/dist/validate/index.d.ts +95 -0
  59. package/dist/validate/index.d.ts.map +1 -0
  60. package/dist/validate/index.js +201 -0
  61. package/dist/validate/migration-parse.d.ts +17 -0
  62. package/dist/validate/migration-parse.d.ts.map +1 -0
  63. package/dist/validate/migration-parse.js +11 -0
  64. package/package.json +53 -1
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Studio SDK surface (S3-02 + S3-03 + early S4-07 prep).
3
+ *
4
+ * Pure helpers for Studio extension authors:
5
+ * - `makeFormProxy` / `applyFormAlterHooks` — form-alter pipeline that
6
+ * `<Form>` renderers in core Studio call before render. Hooks add
7
+ * fields, hide fields, reorder, attach validators.
8
+ * - `sortSlotContributions` — stable priority-asc sort used by
9
+ * `<Slot name>` to order extension contributions.
10
+ *
11
+ * The reactive registries themselves (Svelte 5 `$state`) live in
12
+ * `packages/studio/src/lib/extension-api.svelte.ts`. They import this
13
+ * module so the algorithm is one source of truth, while the studio side
14
+ * owns the runtime state.
15
+ *
16
+ * Exported via `@zveltio/sdk/studio`. Extension Svelte bundles can
17
+ * import-and-call too — useful for advanced authors who want to apply
18
+ * alters against their own custom forms.
19
+ */
20
+ export function makeFormProxy(initial) {
21
+ // Deep-copy fields so hooks can mutate without leaking to the original.
22
+ const fields = initial.fields.map((f) => ({
23
+ ...f,
24
+ validators: [...(f.validators ?? [])],
25
+ }));
26
+ const hiddenSet = new Set();
27
+ return {
28
+ get fields() {
29
+ return fields;
30
+ },
31
+ addField({ after, before, field }) {
32
+ if (!field?.name) {
33
+ console.warn('[form-alter] addField: field.name is required');
34
+ return;
35
+ }
36
+ if (fields.some((f) => f.name === field.name)) {
37
+ console.warn(`[form-alter] addField: field "${field.name}" already exists — use mutate-by-name instead`);
38
+ return;
39
+ }
40
+ const target = after ?? before;
41
+ const cloned = { ...field, validators: [...(field.validators ?? [])] };
42
+ if (target) {
43
+ const idx = fields.findIndex((f) => f.name === target);
44
+ if (idx < 0) {
45
+ // Anchor not found → push to end. Warn so the author knows.
46
+ console.warn(`[form-alter] addField: anchor "${target}" not found; appending "${field.name}" at end`);
47
+ fields.push(cloned);
48
+ }
49
+ else {
50
+ const at = after ? idx + 1 : idx;
51
+ fields.splice(at, 0, cloned);
52
+ }
53
+ }
54
+ else {
55
+ fields.push(cloned);
56
+ }
57
+ },
58
+ hideField(name) {
59
+ hiddenSet.add(name);
60
+ },
61
+ reorder(order) {
62
+ // Reorder only what's in `order`. Fields not mentioned keep their
63
+ // original relative order, appended after the reordered block.
64
+ const byName = new Map(fields.map((f) => [f.name, f]));
65
+ const reorderedKnown = order.map((n) => byName.get(n)).filter(Boolean);
66
+ const remaining = fields.filter((f) => !order.includes(f.name));
67
+ fields.length = 0;
68
+ fields.push(...reorderedKnown, ...remaining);
69
+ },
70
+ addValidator(fieldName, validator) {
71
+ const f = fields.find((x) => x.name === fieldName);
72
+ if (!f) {
73
+ console.warn(`[form-alter] addValidator: field "${fieldName}" not found`);
74
+ return;
75
+ }
76
+ f.validators = [...(f.validators ?? []), validator];
77
+ },
78
+ commit() {
79
+ // Materialize hidden state. `hidden: true` keeps the field in the
80
+ // schema so server-side defaults still apply; renderers skip rendering.
81
+ const out = fields.map((f) => (hiddenSet.has(f.name) ? { ...f, hidden: true } : f));
82
+ return { ...initial, fields: out };
83
+ },
84
+ };
85
+ }
86
+ /**
87
+ * Run every registered hook against the schema and return the result.
88
+ * Exception-safe: a throwing hook is logged and skipped, the rest still run.
89
+ */
90
+ export function applyFormAlterHooks(hooks, schema, ctx = {}) {
91
+ if (hooks.length === 0)
92
+ return schema;
93
+ const proxy = makeFormProxy(schema);
94
+ for (const hook of hooks) {
95
+ try {
96
+ hook(proxy, ctx);
97
+ }
98
+ catch (err) {
99
+ console.error('[form-alter] hook threw:', err);
100
+ }
101
+ }
102
+ return proxy.commit();
103
+ }
104
+ /**
105
+ * Sort slot contributions deterministically: priority asc (lower first),
106
+ * then registration order (stable sort).
107
+ */
108
+ export function sortSlotContributions(items) {
109
+ // We rely on Array.prototype.sort being stable (TC39 since 2019).
110
+ return items.slice().sort((a, b) => (a.priority ?? 100) - (b.priority ?? 100));
111
+ }
112
+ function getStudioGlobal() {
113
+ if (typeof window === 'undefined')
114
+ return null;
115
+ const g = window.__zveltio;
116
+ if (!g) {
117
+ console.warn('[zveltio/sdk/studio] window.__zveltio is not installed. Is the bundle running inside Studio?');
118
+ return null;
119
+ }
120
+ return g;
121
+ }
122
+ /** Register a top-level Studio page contributed by this extension. */
123
+ export function registerRoute(route) {
124
+ getStudioGlobal()?.registerRoute(route);
125
+ }
126
+ /**
127
+ * Register a Svelte component into a named composition slot (S3-03).
128
+ *
129
+ * @example
130
+ * import { registerSlot } from '@zveltio/sdk/studio';
131
+ * import RevenueWidget from './widgets/RevenueWidget.svelte';
132
+ *
133
+ * registerSlot('dashboard.widgets', {
134
+ * component: RevenueWidget,
135
+ * priority: 10,
136
+ * visible: (ctx) => (ctx.user as any)?.roles?.includes('finance'),
137
+ * });
138
+ */
139
+ export function registerSlot(name, contribution) {
140
+ getStudioGlobal()?.registerSlot(name, contribution);
141
+ }
142
+ /**
143
+ * Register a Drupal-style form-alter hook (S3-02).
144
+ *
145
+ * @example
146
+ * import { registerFormAlter } from '@zveltio/sdk/studio';
147
+ *
148
+ * registerFormAlter('core:user-edit', (form) => {
149
+ * form.addField({ after: 'email', field: { name: 'phone', type: 'tel' } });
150
+ * form.hideField('legacy_pin');
151
+ * });
152
+ */
153
+ export function registerFormAlter(formId, hook) {
154
+ getStudioGlobal()?.registerFormAlter(formId, hook);
155
+ }
156
+ /** Register a custom Studio field type contributed by this extension. */
157
+ export function registerFieldType(ft) {
158
+ getStudioGlobal()?.registerFieldType(ft);
159
+ }
160
+ /**
161
+ * Register a custom preview handler for asset URLs / MIME types.
162
+ *
163
+ * @example
164
+ * import { registerAssetPreview } from '@zveltio/sdk/studio';
165
+ * import PdfPreview from './PdfPreview.svelte';
166
+ *
167
+ * registerAssetPreview({
168
+ * match: (a) => a.mimeType === 'application/pdf',
169
+ * component: PdfPreview,
170
+ * });
171
+ */
172
+ export function registerAssetPreview(handler) {
173
+ getStudioGlobal()?.registerAssetPreview(handler);
174
+ }
175
+ /** Engine base URL — pre-installed by Studio for cross-origin fetches. */
176
+ export function getEngineUrl() {
177
+ if (typeof window === 'undefined')
178
+ return '';
179
+ return window.__ZVELTIO_ENGINE_URL__ || getStudioGlobal()?.engineUrl || '';
180
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"svelte.d.ts","sourceRoot":"","sources":["../src/svelte.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAErD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAC/B,IAAI,EAAE,WAAW,EACjB,UAAU,EAAE,MAAM,EAClB,MAAM,EAAE,CAAC,OAAO,EAAE,GAAG,EAAE,KAAK,IAAI,GAC/B,MAAM,IAAI,CAGZ;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAC3B,IAAI,EAAE,WAAW,EACjB,MAAM,EAAE,CAAC,MAAM,EAAE;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,OAAO,CAAC;CACnB,KAAK,IAAI,EACV,UAAU,SAAO,GAChB,MAAM,IAAI,CAeZ"}
1
+ {"version":3,"file":"svelte.d.ts","sourceRoot":"","sources":["../src/svelte.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAErD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAC/B,IAAI,EAAE,WAAW,EACjB,UAAU,EAAE,MAAM,EAClB,MAAM,EAAE,CAAC,OAAO,EAAE,GAAG,EAAE,KAAK,IAAI,GAC/B,MAAM,IAAI,CAGZ;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAC3B,IAAI,EAAE,WAAW,EACjB,MAAM,EAAE,CAAC,MAAM,EAAE;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,OAAO,CAAA;CAAE,KAAK,IAAI,EACnF,UAAU,SAAO,GAChB,MAAM,IAAI,CAeZ"}
@@ -1 +1 @@
1
- {"version":3,"file":"sync-manager.d.ts","sourceRoot":"","sources":["../src/sync-manager.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAMjD,MAAM,MAAM,sBAAsB,GAAG,KAAK,GAAG,QAAQ,CAAC;AAEtD,MAAM,WAAW,iBAAiB;IAChC,0CAA0C;IAC1C,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,oDAAoD;IACpD,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,qDAAqD;IACrD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,oDAAoD;IACpD,UAAU,CAAC,EAAE,CAAC,KAAK,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,KAAK,GAAG,CAAC;IAC9C,mHAAmH;IACnH,kBAAkB,CAAC,EAAE,sBAAsB,CAAC;CAC7C;AAED,qBAAa,WAAW;IACtB,OAAO,CAAC,KAAK,CAAa;IAC1B,OAAO,CAAC,MAAM,CAAgB;IAC9B,OAAO,CAAC,QAAQ,CAAgC;IAChD,OAAO,CAAC,MAAM,CAA8B;IAC5C,OAAO,CAAC,SAAS,CAA+C;IAChE,OAAO,CAAC,QAAQ,CAAiB;IACjC,OAAO,CAAC,SAAS,CAAkB;IACnC,OAAO,CAAC,SAAS,CAAyD;gBAE9D,MAAM,EAAE,aAAa,EAAE,MAAM,GAAE,iBAAsB;IAY3D,KAAK,CAAC,WAAW,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAiChD;;;;;OAKG;IACH,UAAU,CAAC,IAAI,EAAE,MAAM;QAEnB,2CAA2C;;;;;QAU3C,6CAA6C;kBAC7B,MAAM;;;;QAUtB,0CAA0C;uBACrB,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC;;;;QAYxC,0CAA0C;qBACvB,MAAM,QAAQ,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC;;;;QAapD,+CAA+C;qBAC5B,MAAM;QAMzB,qDAAqD;8BAC/B,CAAC,OAAO,EAAE,GAAG,EAAE,KAAK,IAAI;QAoD9C,8CAA8C;;QAK9C,kCAAkC;8BAE5B,MAAM,gBACI,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC;;IASvC,oCAAoC;IAC9B,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;YA8GhB,eAAe;IAmB7B,wDAAwD;IAClD,SAAS,IAAI,OAAO,CAAC;QACzB,OAAO,EAAE,MAAM,CAAC;QAChB,SAAS,EAAE,MAAM,CAAC;QAClB,QAAQ,EAAE,OAAO,CAAC;KACnB,CAAC;IAUI,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;CAK5B"}
1
+ {"version":3,"file":"sync-manager.d.ts","sourceRoot":"","sources":["../src/sync-manager.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAMjD,MAAM,MAAM,sBAAsB,GAAG,KAAK,GAAG,QAAQ,CAAC;AAEtD,MAAM,WAAW,iBAAiB;IAChC,0CAA0C;IAC1C,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,oDAAoD;IACpD,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,qDAAqD;IACrD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,oDAAoD;IACpD,UAAU,CAAC,EAAE,CAAC,KAAK,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,KAAK,GAAG,CAAC;IAC9C,mHAAmH;IACnH,kBAAkB,CAAC,EAAE,sBAAsB,CAAC;CAC7C;AAED,qBAAa,WAAW;IACtB,OAAO,CAAC,KAAK,CAAa;IAC1B,OAAO,CAAC,MAAM,CAAgB;IAC9B,OAAO,CAAC,QAAQ,CAAgC;IAChD,OAAO,CAAC,MAAM,CAA8B;IAC5C,OAAO,CAAC,SAAS,CAA+C;IAChE,OAAO,CAAC,QAAQ,CAAiB;IACjC,OAAO,CAAC,SAAS,CAAkB;IACnC,OAAO,CAAC,SAAS,CAAyD;gBAE9D,MAAM,EAAE,aAAa,EAAE,MAAM,GAAE,iBAAsB;IAY3D,KAAK,CAAC,WAAW,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IA8BhD;;;;;OAKG;IACH,UAAU,CAAC,IAAI,EAAE,MAAM;QAEnB,2CAA2C;;;;;QAU3C,6CAA6C;kBAC7B,MAAM;;;;QAUtB,0CAA0C;uBACrB,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC;;;;QAYxC,0CAA0C;qBACvB,MAAM,QAAQ,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC;;;;QAapD,+CAA+C;qBAC5B,MAAM;QAMzB,qDAAqD;8BAC/B,CAAC,OAAO,EAAE,GAAG,EAAE,KAAK,IAAI;QA0C9C,8CAA8C;;QAK9C,kCAAkC;8BACN,MAAM,gBAAgB,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC;;IAQzE,oCAAoC;IAC9B,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;YA8FhB,eAAe;IAmB7B,wDAAwD;IAClD,SAAS,IAAI,OAAO,CAAC;QACzB,OAAO,EAAE,MAAM,CAAC;QAChB,SAAS,EAAE,MAAM,CAAC;QAClB,QAAQ,EAAE,OAAO,CAAC;KACnB,CAAC;IAUI,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;CAK5B"}
@@ -115,12 +115,9 @@ export class SyncManager {
115
115
  if (this.realtime) {
116
116
  unsubRealtime = this.realtime.subscribe(name, async (event) => {
117
117
  // Apply update from server to local store
118
- if (event.event === 'record.created' ||
119
- event.event === 'record.updated') {
118
+ if (event.event === 'record.created' || event.event === 'record.updated') {
120
119
  try {
121
- const serverRecord = await this.client
122
- .collection(name)
123
- .get(event.record_id);
120
+ const serverRecord = await this.client.collection(name).get(event.record_id);
124
121
  await this.store.applyServerUpdate(name, event.record_id, serverRecord, Date.now());
125
122
  this.notifyListeners(name);
126
123
  }
@@ -209,9 +206,7 @@ export class SyncManager {
209
206
  .create({ id: op.recordId, ...crdtPayload });
210
207
  break;
211
208
  case 'update':
212
- await this.client
213
- .collection(op.collection)
214
- .update(op.recordId, crdtPayload);
209
+ await this.client.collection(op.collection).update(op.recordId, crdtPayload);
215
210
  break;
216
211
  case 'delete':
217
212
  await this.client.collection(op.collection).delete(op.recordId);
@@ -224,9 +219,7 @@ export class SyncManager {
224
219
  // Conflict from server (409) — apply conflict resolution
225
220
  if (err.message?.includes('409')) {
226
221
  try {
227
- const serverRecord = await this.client
228
- .collection(op.collection)
229
- .get(op.recordId);
222
+ const serverRecord = await this.client.collection(op.collection).get(op.recordId);
230
223
  const localRecord = await this.store.get(op.collection, op.recordId);
231
224
  // CRDT LWW merge if both sides have CRDT docs and strategy is 'lww'
232
225
  if (this.config.conflictResolution === 'lww' &&
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Testing helpers for Zveltio extensions (S4-06).
3
+ *
4
+ * Goal: give extension authors a one-liner to spin up a fake
5
+ * `ExtensionContext` and a Hono test app, so unit tests run without a
6
+ * real database or auth setup. For integration tests against a real
7
+ * Postgres + engine, use Bun's `testcontainers` directly — this module
8
+ * is the offline-fast path.
9
+ *
10
+ * Three primitives:
11
+ * - `createTestContext(overrides?)` — minimal `ExtensionContext` with
12
+ * no-op stubs for everything (db, auth, events, services,
13
+ * queryAlter, entityAccess, registerPublicRoute). Override per-test.
14
+ * - `createTestApp(extension, ctx?)` — runs `extension.register()`
15
+ * against a fresh Hono and returns the app, ready for `.request()`.
16
+ * - `mockDb(presets?)` — records query-builder calls; returns presets
17
+ * when an `.execute()` / `.executeTakeFirst()` lands. Enough to
18
+ * fake CRUD paths; falls back to empty arrays / undefined.
19
+ */
20
+ import { Hono } from 'hono';
21
+ import type { ZveltioExtension, ExtensionContext } from '../extension/index.js';
22
+ export interface MockCall {
23
+ /** Method name chain, e.g. `'selectFrom.where.execute'`. */
24
+ chain: string;
25
+ /** Args of the terminal call. */
26
+ args: unknown[];
27
+ }
28
+ export interface MockDbPresets {
29
+ /**
30
+ * Preset a return value for a specific chain. Match is by suffix —
31
+ * `'selectFrom.zvd_users.execute'` matches the chain ending in that.
32
+ * Use exact chains for precision.
33
+ *
34
+ * @example
35
+ * mockDb({
36
+ * 'selectFrom.zvd_users.selectAll.execute': [{ id: '1', name: 'A' }],
37
+ * 'selectFrom.zvd_users.executeTakeFirst': { id: '1', name: 'A' },
38
+ * })
39
+ */
40
+ [chain: string]: unknown;
41
+ }
42
+ export interface MockDb {
43
+ /** Recorded calls, oldest first. Reset between tests. */
44
+ readonly calls: MockCall[];
45
+ /** Reset call history (presets stay). */
46
+ reset(): void;
47
+ /** Add or override a preset after construction. */
48
+ preset(chain: string, value: unknown): void;
49
+ }
50
+ /**
51
+ * Build a recording mock Kysely-shaped object. Any method chain is
52
+ * accepted; the terminal `.execute()` / `.executeTakeFirst()` /
53
+ * `.executeTakeFirstOrThrow()` returns a preset if matched, otherwise
54
+ * an empty list (for execute) or undefined.
55
+ */
56
+ export declare function mockDb(presets?: MockDbPresets): any & MockDb;
57
+ export interface MockEventBus {
58
+ emit(event: string, payload: unknown): void;
59
+ on(event: string, handler: (payload: any) => void): () => void;
60
+ onBefore(event: string, handler: (payload: any) => any): () => void;
61
+ runBefore(event: string, seed: Record<string, unknown>): Promise<any>;
62
+ /** Recorded emits, oldest first. */
63
+ readonly emitted: Array<{
64
+ event: string;
65
+ payload: unknown;
66
+ }>;
67
+ /** Reset emit log. */
68
+ reset(): void;
69
+ }
70
+ export declare function mockEventBus(): MockEventBus;
71
+ export declare function mockServiceRegistry(): {
72
+ register(name: string, value: unknown): void;
73
+ unregister(name: string): void;
74
+ get<T = unknown>(name: string): T | null;
75
+ has(name: string): boolean;
76
+ list(): string[];
77
+ waitFor<T = unknown>(name: string, timeoutMs?: number): Promise<T>;
78
+ };
79
+ export interface MockAuthOptions {
80
+ /** If set, getSession returns `{ user: this }`. If null, getSession returns null. */
81
+ user?: {
82
+ id: string;
83
+ email?: string;
84
+ name?: string;
85
+ roles?: string[];
86
+ } | null;
87
+ }
88
+ export declare function mockAuth(opts?: MockAuthOptions): any;
89
+ export interface CreateTestContextOptions {
90
+ /** Override the mocked db. Default: `mockDb()`. */
91
+ db?: any;
92
+ /** Override the mocked auth. Default: signed-in test user. */
93
+ auth?: any;
94
+ /** Override the mocked event bus. Default: in-memory. */
95
+ events?: MockEventBus;
96
+ /** Provide additional fields. They go onto the returned ctx as-is. */
97
+ extra?: Partial<ExtensionContext<any>>;
98
+ }
99
+ export declare function createTestContext(opts?: CreateTestContextOptions): ExtensionContext<any>;
100
+ export interface CreateTestAppOptions {
101
+ /** Override the context handed to register(). Default: `createTestContext()`. */
102
+ ctx?: ExtensionContext<any>;
103
+ /**
104
+ * When the extension uses `mountStrategy: 'subapp'`, the engine normally
105
+ * mounts the sub-app at `/ext/<name>`. For tests we replicate that so
106
+ * `.request('/ext/<name>/...')` works. Pass `false` to skip the wrapper
107
+ * and mount the sub-app at root for cleaner test URLs.
108
+ */
109
+ mountSubappAt?: string | false;
110
+ }
111
+ /**
112
+ * Spin up a Hono test app with the extension's routes registered. Returns
113
+ * the outer Hono so callers can use `.request(path, init)` for assertions.
114
+ *
115
+ * Honors the extension's `mountStrategy`. For `'subapp'`, the outer app
116
+ * mounts the sub-app at `/ext/<extension.name>` by default — same shape
117
+ * the engine produces in production.
118
+ */
119
+ export declare function createTestApp(extension: ZveltioExtension<any>, opts?: CreateTestAppOptions): Promise<Hono>;
120
+ export { withTestDb, startTestDb, applyMigrationStrings, applyMigrationFiles, stopReusedTestDb, splitStatements, type TestDb, type WithTestDbOptions, } from './with-test-db.js';
121
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/testing/index.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Testing helpers for Zveltio extensions (S4-06).
3
+ *
4
+ * Goal: give extension authors a one-liner to spin up a fake
5
+ * `ExtensionContext` and a Hono test app, so unit tests run without a
6
+ * real database or auth setup. For integration tests against a real
7
+ * Postgres + engine, use Bun's `testcontainers` directly — this module
8
+ * is the offline-fast path.
9
+ *
10
+ * Three primitives:
11
+ * - `createTestContext(overrides?)` — minimal `ExtensionContext` with
12
+ * no-op stubs for everything (db, auth, events, services,
13
+ * queryAlter, entityAccess, registerPublicRoute). Override per-test.
14
+ * - `createTestApp(extension, ctx?)` — runs `extension.register()`
15
+ * against a fresh Hono and returns the app, ready for `.request()`.
16
+ * - `mockDb(presets?)` — records query-builder calls; returns presets
17
+ * when an `.execute()` / `.executeTakeFirst()` lands. Enough to
18
+ * fake CRUD paths; falls back to empty arrays / undefined.
19
+ */
20
+ import { Hono } from 'hono';
21
+ /**
22
+ * Build a recording mock Kysely-shaped object. Any method chain is
23
+ * accepted; the terminal `.execute()` / `.executeTakeFirst()` /
24
+ * `.executeTakeFirstOrThrow()` returns a preset if matched, otherwise
25
+ * an empty list (for execute) or undefined.
26
+ */
27
+ export function mockDb(presets = {}) {
28
+ const calls = [];
29
+ const presetMap = new Map(Object.entries(presets));
30
+ function buildChain(prefix) {
31
+ const proxy = new Proxy(() => { }, {
32
+ get(_, prop) {
33
+ if (typeof prop === 'symbol')
34
+ return undefined;
35
+ if (prop === 'calls')
36
+ return calls;
37
+ if (prop === 'reset')
38
+ return () => {
39
+ calls.length = 0;
40
+ };
41
+ if (prop === 'preset')
42
+ return (chain, value) => {
43
+ presetMap.set(chain, value);
44
+ };
45
+ if (prop === 'then')
46
+ return undefined;
47
+ // Build next link in the chain.
48
+ return buildChain([...prefix, prop]);
49
+ },
50
+ // NOTE: synchronous apply. Terminal calls return a Promise; non-
51
+ // terminal calls return the proxy itself so further chaining works
52
+ // without awaiting. (Async apply would wrap every step in a Promise
53
+ // and break `.selectFrom().selectAll().execute()` chains.)
54
+ apply(_t, _this, args) {
55
+ const chain = prefix.join('.');
56
+ calls.push({ chain, args });
57
+ const terminal = prefix[prefix.length - 1];
58
+ const isTerminal = terminal === 'execute' ||
59
+ terminal === 'executeTakeFirst' ||
60
+ terminal === 'executeTakeFirstOrThrow';
61
+ if (isTerminal) {
62
+ const preset = matchPreset(chain, presetMap);
63
+ if (preset !== undefined) {
64
+ const resolved = typeof preset === 'function' ? preset(...args) : preset;
65
+ return Promise.resolve(resolved);
66
+ }
67
+ if (terminal === 'execute')
68
+ return Promise.resolve([]);
69
+ if (terminal === 'executeTakeFirst')
70
+ return Promise.resolve(undefined);
71
+ // executeTakeFirstOrThrow with no preset → reject
72
+ return Promise.reject(new Error(`mockDb: no preset for chain "${chain}" and executeTakeFirstOrThrow has no fallback`));
73
+ }
74
+ // Non-terminal: keep chaining on the same proxy (its prefix is
75
+ // unchanged; the next property access will extend via get()).
76
+ return proxy;
77
+ },
78
+ });
79
+ return proxy;
80
+ }
81
+ return buildChain([]);
82
+ }
83
+ function matchPreset(chain, presets) {
84
+ // Exact match wins.
85
+ if (presets.has(chain))
86
+ return presets.get(chain);
87
+ // Suffix match: preset 'foo.execute' matches 'selectFrom.x.foo.execute'.
88
+ for (const [key, val] of presets) {
89
+ if (chain.endsWith(key))
90
+ return val;
91
+ }
92
+ return undefined;
93
+ }
94
+ export function mockEventBus() {
95
+ const emitted = [];
96
+ const listeners = new Map();
97
+ const beforeHandlers = new Map();
98
+ return {
99
+ emit(event, payload) {
100
+ emitted.push({ event, payload });
101
+ for (const h of listeners.get(event) ?? [])
102
+ h(payload);
103
+ },
104
+ on(event, handler) {
105
+ const list = listeners.get(event) ?? [];
106
+ list.push(handler);
107
+ listeners.set(event, list);
108
+ return () => {
109
+ const cur = listeners.get(event);
110
+ if (cur) {
111
+ const idx = cur.indexOf(handler);
112
+ if (idx >= 0)
113
+ cur.splice(idx, 1);
114
+ }
115
+ };
116
+ },
117
+ onBefore(event, handler) {
118
+ const list = beforeHandlers.get(event) ?? [];
119
+ list.push(handler);
120
+ beforeHandlers.set(event, list);
121
+ return () => {
122
+ const cur = beforeHandlers.get(event);
123
+ if (cur) {
124
+ const idx = cur.indexOf(handler);
125
+ if (idx >= 0)
126
+ cur.splice(idx, 1);
127
+ }
128
+ };
129
+ },
130
+ async runBefore(event, seed) {
131
+ const payload = { ...seed };
132
+ let aborted = false;
133
+ let abortReason = '';
134
+ payload.abort = (reason) => {
135
+ aborted = true;
136
+ abortReason = reason;
137
+ const err = new Error(`Aborted: ${reason}`);
138
+ err.name = 'AbortHookError';
139
+ err.reason = reason;
140
+ throw err;
141
+ };
142
+ payload.mutate = (patch) => {
143
+ const key = event === 'record.beforeUpdate' ? 'patch' : 'data';
144
+ payload[key] = { ...payload[key], ...patch };
145
+ };
146
+ for (const h of beforeHandlers.get(event) ?? []) {
147
+ await h(payload);
148
+ }
149
+ return payload;
150
+ },
151
+ get emitted() {
152
+ return emitted;
153
+ },
154
+ reset() {
155
+ emitted.length = 0;
156
+ listeners.clear();
157
+ beforeHandlers.clear();
158
+ },
159
+ };
160
+ }
161
+ // ─── Mock service registry ─────────────────────────────────────────────────
162
+ export function mockServiceRegistry() {
163
+ const services = new Map();
164
+ return {
165
+ register(name, value) {
166
+ services.set(name, value);
167
+ },
168
+ unregister(name) {
169
+ services.delete(name);
170
+ },
171
+ get(name) {
172
+ return services.get(name) ?? null;
173
+ },
174
+ has(name) {
175
+ return services.has(name);
176
+ },
177
+ list() {
178
+ return [...services.keys()];
179
+ },
180
+ async waitFor(name) {
181
+ const v = services.get(name);
182
+ if (v === undefined)
183
+ throw new Error(`mockServiceRegistry: no service "${name}" registered`);
184
+ return v;
185
+ },
186
+ };
187
+ }
188
+ // ─── Mock queryAlter / entityAccess scoped registries ──────────────────────
189
+ function mockQueryAlterScope() {
190
+ const entries = [];
191
+ return {
192
+ register(def) {
193
+ entries.push(def);
194
+ },
195
+ list() {
196
+ return entries.map((e) => ({ table: e.table }));
197
+ },
198
+ unregisterAll() {
199
+ entries.length = 0;
200
+ },
201
+ };
202
+ }
203
+ function mockEntityAccessScope() {
204
+ const entries = [];
205
+ return {
206
+ register(def) {
207
+ entries.push(def);
208
+ },
209
+ list() {
210
+ return entries.map((e) => ({ table: e.table }));
211
+ },
212
+ unregisterAll() {
213
+ entries.length = 0;
214
+ },
215
+ };
216
+ }
217
+ export function mockAuth(opts = {}) {
218
+ const user = opts.user === undefined ? { id: 'test-user', email: 'test@example.com', roles: [] } : opts.user;
219
+ return {
220
+ api: {
221
+ async getSession() {
222
+ return user === null ? null : { user };
223
+ },
224
+ },
225
+ };
226
+ }
227
+ export function createTestContext(opts = {}) {
228
+ const db = opts.db ?? mockDb();
229
+ const events = opts.events ?? mockEventBus();
230
+ return {
231
+ db,
232
+ auth: opts.auth ?? mockAuth(),
233
+ fieldTypeRegistry: {
234
+ register: () => { },
235
+ get: () => undefined,
236
+ has: () => false,
237
+ list: () => [],
238
+ getAll: () => [],
239
+ deserialize: (_t, v) => v,
240
+ serialize: (_t, v) => v,
241
+ },
242
+ events: events,
243
+ checkPermission: async () => true,
244
+ getUserRoles: async () => ['admin'],
245
+ DDLManager: {},
246
+ services: mockServiceRegistry(),
247
+ queryAlter: mockQueryAlterScope(),
248
+ entityAccess: mockEntityAccessScope(),
249
+ registerPublicRoute: () => { },
250
+ internals: {},
251
+ ...(opts.extra ?? {}),
252
+ };
253
+ }
254
+ /**
255
+ * Spin up a Hono test app with the extension's routes registered. Returns
256
+ * the outer Hono so callers can use `.request(path, init)` for assertions.
257
+ *
258
+ * Honors the extension's `mountStrategy`. For `'subapp'`, the outer app
259
+ * mounts the sub-app at `/ext/<extension.name>` by default — same shape
260
+ * the engine produces in production.
261
+ */
262
+ export async function createTestApp(extension, opts = {}) {
263
+ const ctx = opts.ctx ?? createTestContext();
264
+ const strategy = extension.mountStrategy ?? 'global';
265
+ if (strategy === 'subapp') {
266
+ const outer = new Hono();
267
+ const sub = new Hono();
268
+ await extension.register(sub, ctx);
269
+ const mountPath = opts.mountSubappAt === false ? '/' : (opts.mountSubappAt ?? `/ext/${extension.name}`);
270
+ outer.route(mountPath, sub);
271
+ return outer;
272
+ }
273
+ const app = new Hono();
274
+ await extension.register(app, ctx);
275
+ return app;
276
+ }
277
+ // ─── Integration test helpers (Postgres) ─────────────────────────────────
278
+ //
279
+ // `withTestDb` + friends live in a separate module so the testcontainers
280
+ // dynamic import doesn't fan out on every consumer of `mockDb` /
281
+ // `createTestContext`. Re-exported here for the
282
+ // `import { withTestDb } from '@zveltio/sdk/testing'` ergonomics.
283
+ export { withTestDb, startTestDb, applyMigrationStrings, applyMigrationFiles, stopReusedTestDb, splitStatements, } from './with-test-db.js';