@tanglemedia/svelte-starter-toolbelt 2.0.0-next.1 → 2.0.0-next.3

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/README.md CHANGED
@@ -1,58 +1,63 @@
1
- # Svelte library
1
+ # @tanglemedia/svelte-starter-toolbelt
2
2
 
3
- Everything you need to build a Svelte library, powered by [`sv`](https://npmjs.com/package/sv).
3
+ Shared service, query, mutation, proxy, and utility helpers used across the Svelte Starter.
4
4
 
5
- Read more about creating a library [in the docs](https://svelte.dev/docs/kit/packaging).
5
+ ## Service providers
6
6
 
7
- ## Creating a project
7
+ `createServiceFactory` builds typed collection services on top of a provider:
8
8
 
9
- If you're seeing this, you've probably already done this step. Congrats!
9
+ - `createDirectusServiceProvider` for Directus-backed collections
10
+ - `createFetchServiceProvider` for generic REST APIs
11
+ - `createFixtureServiceProvider` for local JSON fixtures served through core's `fixture` adapter
10
12
 
11
- ```bash
12
- # create a new project in the current directory
13
- npx sv create
13
+ ### Fixture provider example
14
14
 
15
- # create a new project in my-app
16
- npx sv create my-app
17
- ```
15
+ ```typescript
16
+ import {
17
+ createFixtureServiceProvider,
18
+ createServiceFactory
19
+ } from '@tanglemedia/svelte-starter-toolbelt';
20
+ import configuredApp from '$boot';
18
21
 
19
- ## Developing
22
+ type User = {
23
+ id: number;
24
+ name: string;
25
+ };
20
26
 
21
- Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
27
+ type Schema = {
28
+ users: User;
29
+ };
22
30
 
23
- ```bash
24
- npm run dev
31
+ export const createService = createServiceFactory<Schema, typeof createFixtureServiceProvider>(
32
+ configuredApp,
33
+ createFixtureServiceProvider
34
+ );
25
35
 
26
- # or start the server and open the app in a new browser tab
27
- npm run dev -- --open
36
+ export const usersService = createService('users');
37
+ export const { findUsers, findUsersById } = usersService;
28
38
  ```
29
39
 
30
- Everything inside `src/lib` is part of your library, everything inside `src/routes` can be used as a showcase or preview app.
31
-
32
- ## Building
40
+ `createFixtureServiceProvider` defaults `adapterKey` to `'fixture'`, so it works with this
41
+ `api.yml` setup:
33
42
 
34
- To build your library:
43
+ ```yaml
44
+ default: 'fixture'
35
45
 
36
- ```bash
37
- npm run package
46
+ adapters:
47
+ fixture:
48
+ adapter: fixture
49
+ configuration: {}
38
50
  ```
39
51
 
40
- To create a production version of your showcase app:
52
+ If you need a different adapter key:
41
53
 
42
- ```bash
43
- npm run build
54
+ ```typescript
55
+ export const tenantUsersService = createService('users', {
56
+ adapterKey: 'tenant-fixture'
57
+ });
44
58
  ```
45
59
 
46
- You can preview the production build with `npm run preview`.
47
-
48
- > To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
49
-
50
- ## Publishing
60
+ ## More docs
51
61
 
52
- Go into the `package.json` and give your package the desired name through the `"name"` option. Also consider adding a `"license"` field and point it to a `LICENSE` file which you can create from a template (one popular option is the [MIT license](https://opensource.org/license/mit/)).
53
-
54
- To publish your library to [npm](https://www.npmjs.com):
55
-
56
- ```bash
57
- npm publish
58
- ```
62
+ - Package docs: `docs/packages/toolbelt.md`
63
+ - Service-focused guide: `docs/packages/toolbelt-services.md`
@@ -1,2 +1,4 @@
1
1
  export * from './provider/create-directus-service.provider.ts';
2
+ export * from './provider/create-fetch-service.provider.ts';
3
+ export * from './provider/create-fixture-service.provider.ts';
2
4
  export * from './service.factory.ts';
@@ -1,2 +1,4 @@
1
1
  export * from "./provider/create-directus-service.provider.js";
2
+ export * from "./provider/create-fetch-service.provider.js";
3
+ export * from "./provider/create-fixture-service.provider.js";
2
4
  export * from "./service.factory.js";
@@ -747,5 +747,6 @@ export declare class DirectusBaseService<T extends AnyObject> extends ServiceAbs
747
747
  type ServiceProviderOpt<T extends AnyObject> = {
748
748
  serviceClass?: (base: ConfigurableServiceClass<T>) => ConfigurableServiceClass<T, DirectusBaseService<T>>;
749
749
  };
750
+ export declare function isDirectusService<T extends AnyObject>(service: ServiceAbstract<T>): service is DirectusBaseService<T>;
750
751
  export declare function createDirectusServiceProvider({ configureService }: ConfiguredApplication): <T extends AnyObject>(collection: string, opt?: Partial<ServiceAbstractOptions> & ServiceProviderOpt<T>) => DirectusBaseService<T>;
751
752
  export {};
@@ -17,6 +17,9 @@ export class DirectusBaseService extends ServiceAbstract {
17
17
  }
18
18
  }
19
19
  // const extender = (...parts: ConfigurableServiceClass[]) => parts.reduce((acc, ext) => ext(acc), DirectusBaseService);
20
+ export function isDirectusService(service) {
21
+ return service instanceof DirectusBaseService;
22
+ }
20
23
  export function createDirectusServiceProvider({ configureService }) {
21
24
  /**
22
25
  * Variable used for internal caching so that services with the same
@@ -0,0 +1,9 @@
1
+ import { ServiceAbstract, type AnyObject, type ConfigurableServiceClass, type ConfiguredApplication, type ServiceAbstractOptions } from '@tanglemedia/svelte-starter-core';
2
+ export declare class BaseService<T extends AnyObject> extends ServiceAbstract<T> {
3
+ }
4
+ type FetchServiceProviderOpt<T extends AnyObject> = {
5
+ serviceClass?: (base: ConfigurableServiceClass<T, BaseService<T>>) => ConfigurableServiceClass<T, BaseService<T>>;
6
+ adapterKey?: string;
7
+ };
8
+ export declare function createFetchServiceProvider({ configureService }: ConfiguredApplication): <T extends AnyObject>(collection: string, opt?: Partial<ServiceAbstractOptions> & FetchServiceProviderOpt<T>) => BaseService<T>;
9
+ export {};
@@ -0,0 +1,18 @@
1
+ import { ServiceAbstract } from '@tanglemedia/svelte-starter-core';
2
+ export class BaseService extends ServiceAbstract {
3
+ }
4
+ export function createFetchServiceProvider({ configureService }) {
5
+ const services = {};
6
+ return (collection, opt) => {
7
+ const k = JSON.stringify([collection, opt]);
8
+ if (services[k]) {
9
+ return services[k];
10
+ }
11
+ const Service = opt?.serviceClass ? opt.serviceClass(BaseService) : BaseService;
12
+ return (services[k] = configureService(Service, {
13
+ path: collection,
14
+ adapterKey: opt?.adapterKey ?? 'fetch',
15
+ ...(opt ?? {})
16
+ }));
17
+ };
18
+ }
@@ -0,0 +1,9 @@
1
+ import { ServiceAbstract, type AnyObject, type ConfigurableServiceClass, type ConfiguredApplication, type ServiceAbstractOptions } from '@tanglemedia/svelte-starter-core';
2
+ export declare class BaseFixtureService<T extends AnyObject> extends ServiceAbstract<T> {
3
+ }
4
+ type FixtureServiceProviderOpt<T extends AnyObject> = {
5
+ serviceClass?: (base: ConfigurableServiceClass<T, BaseFixtureService<T>>) => ConfigurableServiceClass<T, BaseFixtureService<T>>;
6
+ adapterKey?: string;
7
+ };
8
+ export declare function createFixtureServiceProvider({ configureService }: ConfiguredApplication): <T extends AnyObject>(collection: string, opt?: Partial<ServiceAbstractOptions> & FixtureServiceProviderOpt<T>) => BaseFixtureService<T>;
9
+ export {};
@@ -0,0 +1,18 @@
1
+ import { ServiceAbstract } from '@tanglemedia/svelte-starter-core';
2
+ export class BaseFixtureService extends ServiceAbstract {
3
+ }
4
+ export function createFixtureServiceProvider({ configureService }) {
5
+ const services = {};
6
+ return (collection, opt) => {
7
+ const k = JSON.stringify([collection, opt]);
8
+ if (services[k]) {
9
+ return services[k];
10
+ }
11
+ const Service = opt?.serviceClass ? opt.serviceClass(BaseFixtureService) : BaseFixtureService;
12
+ return (services[k] = configureService(Service, {
13
+ path: collection,
14
+ adapterKey: opt?.adapterKey ?? 'fixture',
15
+ ...(opt ?? {})
16
+ }));
17
+ };
18
+ }
@@ -1,5 +1,5 @@
1
1
  import type { AnyObject, ConfiguredApplication, ServiceAbstract, ServiceAbstractOptions } from "@tanglemedia/svelte-starter-core";
2
- import { DirectusBaseService } from "./provider/create-directus-service.provider";
2
+ import { createDirectusServiceProvider } from "./provider/create-directus-service.provider";
3
3
  import { createApiMutations, createApiQueries } from "../utility/query-factory.ts";
4
4
  import type { CamelCase } from "../types/index.ts";
5
5
  import type { CreateFindOneFactory } from "../utility/query/create-find-one-query.svelte.ts";
@@ -28,7 +28,25 @@ type ServiceFactoryReturn<T extends AnyObject, P extends string, S extends Servi
28
28
  }>)>;
29
29
  };
30
30
  /**
31
- * Initial attempt to reduce the number of boiler plating done for directus services
31
+ * The shape of a provider creator a function that accepts a configured app
32
+ * and returns a bound provider. This matches createDirectusServiceProvider,
33
+ * createFetchServiceProvider, or any developer-supplied custom provider.
32
34
  */
33
- export declare function createServiceFactory<Schema extends Record<string, AnyObject>>(app: ConfiguredApplication): <P extends string>(collection: P, opt?: Partial<ServiceAbstractOptions>) => ServiceFactoryReturn<Schema[P], P, DirectusBaseService<Schema[P]>>;
35
+ export type ProviderCreator = (app: ConfiguredApplication) => <T extends AnyObject>(collection: string, opt?: Partial<ServiceAbstractOptions> & Record<string, unknown>) => ServiceAbstract<T>;
36
+ /**
37
+ * Infers the concrete service instance type from a provider creator PC for a
38
+ * given row type T. Walks PC → ReturnType<PC> (bound provider) → infer R from
39
+ * its return → intersect with ServiceAbstract<T> to bind the concrete T slot.
40
+ *
41
+ * Examples:
42
+ * ExtractService<typeof createDirectusServiceProvider, Product> → DirectusBaseService<Product>
43
+ * ExtractService<typeof createFetchServiceProvider, Product> → BaseService<Product>
44
+ */
45
+ export type ExtractService<PC extends ProviderCreator, T extends AnyObject> = ReturnType<PC> extends <_U extends AnyObject>(collection: string, opt?: any) => infer R ? R extends ServiceAbstract<AnyObject> ? R & ServiceAbstract<T> : ServiceAbstract<T> : ServiceAbstract<T>;
46
+ /**
47
+ * Creates a per-schema service factory. Pass a provider creator as the second
48
+ * argument to choose the adapter backing (Directus or fetch). Defaults to
49
+ * createDirectusServiceProvider for full backward compatibility.
50
+ */
51
+ export declare function createServiceFactory<Schema extends Record<string, AnyObject>, PC extends ProviderCreator = typeof createDirectusServiceProvider>(app: ConfiguredApplication, providerCreator?: PC): <P extends string>(collection: P, opt?: Partial<ServiceAbstractOptions>) => ServiceFactoryReturn<Schema[P], P, ExtractService<PC, Schema[P]>>;
34
52
  export {};
@@ -1,35 +1,37 @@
1
- import { createDirectusServiceProvider, DirectusBaseService } from "./provider/create-directus-service.provider";
1
+ import { createDirectusServiceProvider } from "./provider/create-directus-service.provider";
2
2
  import { createApiMutations, createApiQueries } from "../utility/query-factory.js";
3
3
  import camelCase from 'lodash.camelcase';
4
4
  /**
5
- * Initial attempt to reduce the number of boiler plating done for directus services
5
+ * Creates a per-schema service factory. Pass a provider creator as the second
6
+ * argument to choose the adapter backing (Directus or fetch). Defaults to
7
+ * createDirectusServiceProvider for full backward compatibility.
6
8
  */
7
- export function createServiceFactory(app) {
8
- const serviceProvider = createDirectusServiceProvider(app);
9
- // type K = keyof Schema;
10
- // function factory<T extends AnyObject> (collection: Px, opt?: Partial<ServiceAbstractOptions>): ServiceFactoryReturn<T, Px, DirectusBaseService<T>>;
9
+ export function createServiceFactory(app, providerCreator = createDirectusServiceProvider) {
10
+ // Cast to ReturnType<PC> so the generic call below is typed against the
11
+ // concrete bound provider rather than the loose ProviderCreator upper bound.
12
+ const serviceProvider = providerCreator(app);
11
13
  function factory(collection, opt) {
14
+ // Keep as ServiceAbstract<Schema[P]> inside the body to avoid the deferred
15
+ // conditional type (ExtractService) distributing to a union, which would
16
+ // prevent passing service to createApiMutations. The ExtractService cast
17
+ // is applied only on the final return object.
12
18
  const service = serviceProvider(collection, opt);
13
- // console.log(`FACTORY ${collection}`, service)
14
19
  const createQuries = () => createApiQueries({
15
20
  collection,
16
21
  service
17
22
  });
18
23
  const createMutations = () => createApiMutations({
19
24
  collection,
20
- service
25
+ // service is ServiceAbstract<Schema[P]>; cast to satisfy the id constraint
26
+ // required by createApiMutations. Safe: every concrete service impl handles
27
+ // id-bearing entities at runtime.
28
+ service: service
21
29
  });
22
- // todo: Do the same for mutations
23
30
  const [find, findOne, findInfinite] = createQuries();
24
31
  const [createOne, updateOne, deleteOne] = createMutations();
25
32
  const k1 = camelCase(`find_${collection}`);
26
33
  const k2 = camelCase(`find_${collection}_by_id`);
27
34
  const k3 = camelCase(`find_${collection}_infinite`);
28
- // const queries = {
29
- // [k1]: find,
30
- // [k2]: findOne,
31
- // [k3]: findInfinite,
32
- // };
33
35
  const m1 = camelCase(`create_${collection}`);
34
36
  const m2 = camelCase(`update_${collection}`);
35
37
  const m3 = camelCase(`delete_${collection}`);
@@ -1,6 +1,10 @@
1
- import type { BaseMutationOpt, TMutationBase } from '../types/index.ts';
1
+ import type { BaseMutationOpt, InfiniteQueryOpt, TMutationBase } from '../types/index.ts';
2
2
  import type { ApiPostQuery, ServiceAbstract } from '@tanglemedia/svelte-starter-core';
3
- import { type CreateFindInfiniteQueryOpt } from './query/create-find-infinite-query.svelte.ts';
3
+ export type CreateFindInfiniteQueryOpt<T extends object> = {
4
+ collection: string;
5
+ service: ServiceAbstract<T>;
6
+ options?: InfiniteQueryOpt;
7
+ };
4
8
  export type CreateMutationOpt<T extends TMutationBase> = {
5
9
  collection: string;
6
10
  service: ServiceAbstract<T>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanglemedia/svelte-starter-toolbelt",
3
- "version": "2.0.0-next.1",
3
+ "version": "2.0.0-next.3",
4
4
  "files": [
5
5
  "src",
6
6
  "dist",
@@ -29,7 +29,7 @@
29
29
  },
30
30
  "peerDependencies": {
31
31
  "@directus/sdk": ">=17.0.0",
32
- "@tanglemedia/svelte-starter-core": ">=4.0.0-next.0",
32
+ "@tanglemedia/svelte-starter-core": ">=4.0.0-next.1",
33
33
  "@tanstack/svelte-query": ">=6",
34
34
  "svelte": "^5.0.0"
35
35
  },
@@ -62,8 +62,9 @@
62
62
  "typescript": "^5.9.3",
63
63
  "typescript-eslint": "^8.54.0",
64
64
  "vite": "^8.0.8",
65
+ "msw": "^2.12.7",
65
66
  "vitest": "^4.1.4",
66
- "@tanglemedia/svelte-starter-core": "4.0.0-next.0",
67
+ "@tanglemedia/svelte-starter-core": "4.0.0-next.1",
67
68
  "@tanglemedia/svelte-starter-types": "3.0.0-next.0"
68
69
  },
69
70
  "keywords": [
@@ -1,2 +1,4 @@
1
1
  export * from './provider/create-directus-service.provider.ts';
2
- export * from './service.factory.ts';
2
+ export * from './provider/create-fetch-service.provider.ts';
3
+ export * from './provider/create-fixture-service.provider.ts';
4
+ export * from './service.factory.ts';
@@ -45,6 +45,12 @@ type ServiceProviderOpt<T extends AnyObject> = {
45
45
 
46
46
  // const extender = (...parts: ConfigurableServiceClass[]) => parts.reduce((acc, ext) => ext(acc), DirectusBaseService);
47
47
 
48
+ export function isDirectusService<T extends AnyObject>(
49
+ service: ServiceAbstract<T>
50
+ ): service is DirectusBaseService<T> {
51
+ return service instanceof DirectusBaseService;
52
+ }
53
+
48
54
  export function createDirectusServiceProvider({ configureService }: ConfiguredApplication) {
49
55
 
50
56
  /**
@@ -0,0 +1,37 @@
1
+ import {
2
+ ServiceAbstract,
3
+ type AnyObject,
4
+ type ConfigurableServiceClass,
5
+ type ConfiguredApplication,
6
+ type ServiceAbstractOptions
7
+ } from '@tanglemedia/svelte-starter-core';
8
+
9
+ export class BaseService<T extends AnyObject> extends ServiceAbstract<T> {}
10
+
11
+ type FetchServiceProviderOpt<T extends AnyObject> = {
12
+ serviceClass?: (base: ConfigurableServiceClass<T, BaseService<T>>) => ConfigurableServiceClass<T, BaseService<T>>;
13
+ adapterKey?: string;
14
+ };
15
+
16
+ export function createFetchServiceProvider({ configureService }: ConfiguredApplication) {
17
+ const services: Record<string, unknown> = {};
18
+
19
+ return <T extends AnyObject>(
20
+ collection: string,
21
+ opt?: Partial<ServiceAbstractOptions> & FetchServiceProviderOpt<T>
22
+ ): BaseService<T> => {
23
+ const k = JSON.stringify([collection, opt]);
24
+
25
+ if (services[k]) {
26
+ return services[k] as BaseService<T>;
27
+ }
28
+
29
+ const Service = opt?.serviceClass ? opt.serviceClass(BaseService) : BaseService<T>;
30
+
31
+ return (services[k] = configureService<T, BaseService<T>>(Service, {
32
+ path: collection,
33
+ adapterKey: opt?.adapterKey ?? 'fetch',
34
+ ...(opt ?? {})
35
+ })) as BaseService<T>;
36
+ };
37
+ }
@@ -0,0 +1,94 @@
1
+ import { describe, expect, test, vi } from 'vitest';
2
+ import type { ConfiguredApplication, ServiceAbstractOptions } from '@tanglemedia/svelte-starter-core';
3
+
4
+ import {
5
+ BaseFixtureService,
6
+ createFixtureServiceProvider
7
+ } from './create-fixture-service.provider';
8
+
9
+ describe('createFixtureServiceProvider', () => {
10
+ test('returns a factory function', () => {
11
+ const app = {
12
+ configureService: vi.fn()
13
+ } as unknown as ConfiguredApplication;
14
+
15
+ const createService = createFixtureServiceProvider(app);
16
+
17
+ expect(typeof createService).toBe('function');
18
+ });
19
+
20
+ test('caches service instances for the same collection and options', () => {
21
+ const instance = { service: 'fixture' };
22
+ const app = {
23
+ configureService: vi.fn(() => instance)
24
+ } as unknown as ConfiguredApplication;
25
+ const createService = createFixtureServiceProvider(app);
26
+
27
+ const first = createService('users');
28
+ const second = createService('users');
29
+
30
+ expect(first).toBe(second);
31
+ expect(app.configureService).toHaveBeenCalledOnce();
32
+ });
33
+
34
+ test('defaults adapterKey to fixture', () => {
35
+ const configureService = vi.fn();
36
+ const app = {
37
+ configureService
38
+ } as unknown as ConfiguredApplication;
39
+ const createService = createFixtureServiceProvider(app);
40
+
41
+ createService('users');
42
+
43
+ expect(configureService).toHaveBeenCalledWith(
44
+ BaseFixtureService,
45
+ expect.objectContaining({
46
+ path: 'users',
47
+ adapterKey: 'fixture'
48
+ })
49
+ );
50
+ });
51
+
52
+ test('passes through a custom adapterKey when provided', () => {
53
+ const configureService = vi.fn();
54
+ const app = {
55
+ configureService
56
+ } as unknown as ConfiguredApplication;
57
+ const createService = createFixtureServiceProvider(app);
58
+
59
+ createService('users', {
60
+ adapterKey: 'tenant-fixture'
61
+ });
62
+
63
+ expect(configureService).toHaveBeenCalledWith(
64
+ BaseFixtureService,
65
+ expect.objectContaining({
66
+ adapterKey: 'tenant-fixture'
67
+ })
68
+ );
69
+ });
70
+
71
+ test('uses a custom serviceClass when provided', () => {
72
+ const configureService = vi.fn();
73
+ const app = {
74
+ configureService
75
+ } as unknown as ConfiguredApplication;
76
+ const createService = createFixtureServiceProvider(app);
77
+
78
+ class CustomFixtureService<T extends Record<string, unknown>> extends BaseFixtureService<T> {}
79
+
80
+ createService('users', {
81
+ serviceClass: () => CustomFixtureService,
82
+ throwError: false
83
+ } as Partial<ServiceAbstractOptions>);
84
+
85
+ expect(configureService).toHaveBeenCalledWith(
86
+ CustomFixtureService,
87
+ expect.objectContaining({
88
+ path: 'users',
89
+ adapterKey: 'fixture',
90
+ throwError: false
91
+ })
92
+ );
93
+ });
94
+ });
@@ -0,0 +1,37 @@
1
+ import {
2
+ ServiceAbstract,
3
+ type AnyObject,
4
+ type ConfigurableServiceClass,
5
+ type ConfiguredApplication,
6
+ type ServiceAbstractOptions
7
+ } from '@tanglemedia/svelte-starter-core';
8
+
9
+ export class BaseFixtureService<T extends AnyObject> extends ServiceAbstract<T> {}
10
+
11
+ type FixtureServiceProviderOpt<T extends AnyObject> = {
12
+ serviceClass?: (base: ConfigurableServiceClass<T, BaseFixtureService<T>>) => ConfigurableServiceClass<T, BaseFixtureService<T>>;
13
+ adapterKey?: string;
14
+ };
15
+
16
+ export function createFixtureServiceProvider({ configureService }: ConfiguredApplication) {
17
+ const services: Record<string, unknown> = {};
18
+
19
+ return <T extends AnyObject>(
20
+ collection: string,
21
+ opt?: Partial<ServiceAbstractOptions> & FixtureServiceProviderOpt<T>
22
+ ): BaseFixtureService<T> => {
23
+ const k = JSON.stringify([collection, opt]);
24
+
25
+ if (services[k]) {
26
+ return services[k] as BaseFixtureService<T>;
27
+ }
28
+
29
+ const Service = opt?.serviceClass ? opt.serviceClass(BaseFixtureService) : BaseFixtureService<T>;
30
+
31
+ return (services[k] = configureService<T, BaseFixtureService<T>>(Service, {
32
+ path: collection,
33
+ adapterKey: opt?.adapterKey ?? 'fixture',
34
+ ...(opt ?? {})
35
+ })) as BaseFixtureService<T>;
36
+ };
37
+ }
@@ -0,0 +1,284 @@
1
+ import { setupServer } from 'msw/node';
2
+ import { http, HttpResponse } from 'msw';
3
+ import { afterAll, afterEach, beforeAll, describe, expect, test, vi } from 'vitest';
4
+ import { ApiAdapterProvider } from '@tanglemedia/svelte-starter-core';
5
+ import type { AnyObject, ConfiguredApplication, ServiceAbstractOptions } from '@tanglemedia/svelte-starter-core';
6
+ import { createServiceFactory } from './service.factory';
7
+ import { createDirectusServiceProvider, DirectusBaseService, isDirectusService } from './provider/create-directus-service.provider';
8
+ import { BaseService, createFetchServiceProvider } from './provider/create-fetch-service.provider';
9
+
10
+ vi.mock('$env/dynamic/public', () => ({ env: {} }));
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Mock data
14
+ // ---------------------------------------------------------------------------
15
+
16
+ type Product = { id: number; name: string; price: number };
17
+ type Order = { id: number; productId: number; quantity: number };
18
+
19
+ const products: Product[] = [
20
+ { id: 1, name: 'Widget A', price: 9.99 },
21
+ { id: 2, name: 'Widget B', price: 19.99 }
22
+ ];
23
+
24
+ const orders: Order[] = [
25
+ { id: 1, productId: 1, quantity: 2 },
26
+ { id: 2, productId: 2, quantity: 1 }
27
+ ];
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // MSW server
31
+ // ---------------------------------------------------------------------------
32
+
33
+ const BASE = 'https://api.test.local/api';
34
+
35
+ const handlers = [
36
+ http.get(`${BASE}/product`, () => HttpResponse.json({ data: products })),
37
+ http.get(`${BASE}/product/1`, () => HttpResponse.json({ data: products[0] })),
38
+ http.get(`${BASE}/product/99`, () => HttpResponse.json({}, { status: 404 })),
39
+ http.post(`${BASE}/product`, async ({ request }) => {
40
+ const body = (await request.json()) as Omit<Product, 'id'>;
41
+ const created: Product = { id: products.length + 1, ...body };
42
+ products.push(created);
43
+ return HttpResponse.json({ data: created });
44
+ }),
45
+ http.patch(`${BASE}/product/1`, async ({ request }) => {
46
+ const body = (await request.json()) as Partial<Product>;
47
+ const updated = { ...products[0], ...body };
48
+ products[0] = updated;
49
+ return HttpResponse.json({ data: updated });
50
+ }),
51
+ http.delete(`${BASE}/product/1`, () => HttpResponse.json({ data: { id: 1 } })),
52
+
53
+ http.get(`${BASE}/order`, () => HttpResponse.json({ data: orders }))
54
+ ];
55
+
56
+ const server = setupServer(...handlers);
57
+
58
+ beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
59
+ afterAll(() => server.close());
60
+ afterEach(() => server.resetHandlers());
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Minimal ConfiguredApplication backed by the real fetch adapter
64
+ // ---------------------------------------------------------------------------
65
+
66
+ type Schema = {
67
+ product: Product;
68
+ order: Order;
69
+ };
70
+
71
+ const fetchConfig = {
72
+ api: {
73
+ default: 'fetch',
74
+ adapters: {
75
+ fetch: {
76
+ baseUrl: 'https://api.test.local',
77
+ configuration: { pathPrefix: '/api' }
78
+ }
79
+ }
80
+ }
81
+ };
82
+
83
+ // Build a minimal ConfiguredApplication that uses the real ApiAdapterProvider
84
+ // with a static in-memory config — mirrors the pattern from core service tests.
85
+ const mockConfigStore = {
86
+ config: (key: string): unknown => {
87
+ const cfg: Record<string, unknown> = {
88
+ 'api.default': fetchConfig.api.default,
89
+ 'api.adapters.fetch': fetchConfig.api.adapters.fetch
90
+ };
91
+ return cfg[key];
92
+ }
93
+ };
94
+
95
+ const adapterProvider = new ApiAdapterProvider(async () => mockConfigStore as any);
96
+
97
+ const mockApp: ConfiguredApplication = {
98
+ configureService: <T extends AnyObject, S>(
99
+ ServiceClass: new (p: typeof adapterProvider, o: ServiceAbstractOptions) => S,
100
+ opt: ServiceAbstractOptions
101
+ ) => new ServiceClass(adapterProvider, opt) as S
102
+ } as unknown as ConfiguredApplication;
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // Tests
106
+ // ---------------------------------------------------------------------------
107
+
108
+ describe('createServiceFactory — structure', () => {
109
+ test('factory function is returned', () => {
110
+ const createService = createServiceFactory<Schema, typeof createFetchServiceProvider>(mockApp, createFetchServiceProvider);
111
+ expect(typeof createService).toBe('function');
112
+ });
113
+
114
+ test('factory generates camelCase query method names for a collection', () => {
115
+ const createService = createServiceFactory<Schema, typeof createFetchServiceProvider>(mockApp, createFetchServiceProvider);
116
+ const productService = createService('product');
117
+
118
+ expect(typeof productService.findProduct).toBe('function');
119
+ expect(typeof productService.findProductById).toBe('function');
120
+ expect(typeof productService.findProductInfinite).toBe('function');
121
+ });
122
+
123
+ test('factory generates camelCase mutation method names for a collection', () => {
124
+ const createService = createServiceFactory<Schema, typeof createFetchServiceProvider>(mockApp, createFetchServiceProvider);
125
+ const productService = createService('product');
126
+
127
+ expect(typeof productService.createProduct).toBe('function');
128
+ expect(typeof productService.updateProduct).toBe('function');
129
+ expect(typeof productService.deleteProduct).toBe('function');
130
+ });
131
+
132
+ test('factory exposes createQuries and createMutations helpers', () => {
133
+ const createService = createServiceFactory<Schema, typeof createFetchServiceProvider>(mockApp, createFetchServiceProvider);
134
+ const productService = createService('product');
135
+
136
+ expect(typeof productService.createQuries).toBe('function');
137
+ expect(typeof productService.createMutations).toBe('function');
138
+ });
139
+
140
+ test('method names are scoped to the collection — order vs product', () => {
141
+ const createService = createServiceFactory<Schema, typeof createFetchServiceProvider>(mockApp, createFetchServiceProvider);
142
+ const orderService = createService('order');
143
+
144
+ expect(typeof orderService.findOrder).toBe('function');
145
+ expect(typeof orderService.findOrderById).toBe('function');
146
+ expect(typeof orderService.createOrder).toBe('function');
147
+
148
+ // order factory should NOT have product methods
149
+ expect((orderService as any).findProduct).toBeUndefined();
150
+ });
151
+
152
+ test('each call to factory returns a fresh service object', () => {
153
+ const createService = createServiceFactory<Schema, typeof createFetchServiceProvider>(mockApp, createFetchServiceProvider);
154
+ const a = createService('product');
155
+ const b = createService('order');
156
+ expect(a).not.toBe(b);
157
+ });
158
+ });
159
+
160
+ describe('createServiceFactory — service class types', () => {
161
+ test('fetch provider produces a BaseService instance', () => {
162
+ const createService = createServiceFactory<Schema, typeof createFetchServiceProvider>(mockApp, createFetchServiceProvider);
163
+ const { service } = createService('product');
164
+
165
+ expect(service).toBeInstanceOf(BaseService);
166
+ });
167
+
168
+ test('fetch provider service is NOT a DirectusBaseService', () => {
169
+ const createService = createServiceFactory<Schema, typeof createFetchServiceProvider>(mockApp, createFetchServiceProvider);
170
+ const { service } = createService('product');
171
+
172
+ expect(service).not.toBeInstanceOf(DirectusBaseService);
173
+ });
174
+
175
+ test('directus provider produces a DirectusBaseService instance', () => {
176
+ const createService = createServiceFactory<Schema>(mockApp, createDirectusServiceProvider);
177
+ const { service } = createService('product');
178
+
179
+ expect(service).toBeInstanceOf(DirectusBaseService);
180
+ });
181
+
182
+ test('default (no second arg) produces a DirectusBaseService instance', () => {
183
+ const createService = createServiceFactory<Schema>(mockApp);
184
+ const { service } = createService('product');
185
+
186
+ expect(service).toBeInstanceOf(DirectusBaseService);
187
+ });
188
+ });
189
+
190
+ describe('isDirectusService guard', () => {
191
+ test('returns true for DirectusBaseService', () => {
192
+ const createService = createServiceFactory<Schema>(mockApp, createDirectusServiceProvider);
193
+ const { service } = createService('product');
194
+
195
+ expect(isDirectusService(service)).toBe(true);
196
+ });
197
+
198
+ test('returns false for BaseService', () => {
199
+ const createService = createServiceFactory<Schema, typeof createFetchServiceProvider>(mockApp, createFetchServiceProvider);
200
+ const { service } = createService('product');
201
+
202
+ expect(isDirectusService(service)).toBe(false);
203
+ });
204
+
205
+ test('guard narrows type — getDirectus is accessible after guard', () => {
206
+ const createService = createServiceFactory<Schema>(mockApp, createDirectusServiceProvider);
207
+ const { service } = createService('product');
208
+
209
+ if (isDirectusService(service)) {
210
+ // TypeScript should see service as DirectusBaseService here
211
+ expect(typeof service.getDirectus).toBe('function');
212
+ } else {
213
+ throw new Error('Expected service to be a DirectusBaseService');
214
+ }
215
+ });
216
+ });
217
+
218
+ describe('createServiceFactory — fetch provider HTTP calls', () => {
219
+ test('service.find() returns all products', async () => {
220
+ const createService = createServiceFactory<Schema, typeof createFetchServiceProvider>(mockApp, createFetchServiceProvider);
221
+ const { service } = createService('product');
222
+
223
+ const { data } = await service.find({});
224
+
225
+ expect(data).toHaveLength(products.length);
226
+ expect(data[0]).toMatchObject({ name: 'Widget A' });
227
+ });
228
+
229
+ test('service.findOne() returns a single product by id', async () => {
230
+ const createService = createServiceFactory<Schema, typeof createFetchServiceProvider>(mockApp, createFetchServiceProvider);
231
+ const { service } = createService('product');
232
+
233
+ const { data } = await service.findOne(1);
234
+
235
+ expect(data).toMatchObject({ id: 1, name: 'Widget A' });
236
+ });
237
+
238
+ test('service.create() posts a new product and returns it', async () => {
239
+ const createService = createServiceFactory<Schema, typeof createFetchServiceProvider>(mockApp, createFetchServiceProvider);
240
+ const { service } = createService('product');
241
+
242
+ const { data } = await service.create({ name: 'Widget C', price: 29.99 });
243
+
244
+ expect(data).toMatchObject({ name: 'Widget C', price: 29.99 });
245
+ expect(data).toHaveProperty('id');
246
+ });
247
+
248
+ test('service.update() patches an existing product', async () => {
249
+ const createService = createServiceFactory<Schema, typeof createFetchServiceProvider>(mockApp, createFetchServiceProvider);
250
+ const { service } = createService('product');
251
+
252
+ const { data } = await service.update(1, { name: 'Widget A — Updated' });
253
+
254
+ expect(data).toMatchObject({ id: 1, name: 'Widget A — Updated' });
255
+ });
256
+
257
+ test('service.delete() removes a product', async () => {
258
+ const createService = createServiceFactory<Schema, typeof createFetchServiceProvider>(mockApp, createFetchServiceProvider);
259
+ const { service } = createService('product');
260
+
261
+ const { data } = await service.delete(1);
262
+
263
+ expect(data).toMatchObject({ id: 1 });
264
+ });
265
+
266
+ test('the same service instance is reused for identical collection + options', () => {
267
+ const createService = createServiceFactory<Schema, typeof createFetchServiceProvider>(mockApp, createFetchServiceProvider);
268
+ const a = createService('product');
269
+ const b = createService('product');
270
+
271
+ // createFetchServiceProvider caches by [collection, opt] key
272
+ expect(a.service).toBe(b.service);
273
+ });
274
+
275
+ test('order service can independently fetch orders', async () => {
276
+ const createService = createServiceFactory<Schema, typeof createFetchServiceProvider>(mockApp, createFetchServiceProvider);
277
+ const { service } = createService('order');
278
+
279
+ const { data } = await service.find({});
280
+
281
+ expect(data).toHaveLength(orders.length);
282
+ expect(data[0]).toMatchObject({ productId: 1 });
283
+ });
284
+ });
@@ -1,5 +1,5 @@
1
1
  import type { AnyObject, ConfiguredApplication, ServiceAbstract, ServiceAbstractOptions } from "@tanglemedia/svelte-starter-core";
2
- import { createDirectusServiceProvider, DirectusBaseService } from "./provider/create-directus-service.provider";
2
+ import { createDirectusServiceProvider } from "./provider/create-directus-service.provider";
3
3
  import { createApiMutations, createApiQueries } from "../utility/query-factory.ts";
4
4
  import camelCase from 'lodash.camelcase';
5
5
  import type { CamelCase } from "../types/index.ts";
@@ -24,7 +24,6 @@ type MutationFactories<T extends AnyObject, P extends string> = {
24
24
  } & {
25
25
  [k in CamelCase<`delete_${P}`>]: CreateDeleteMutationFactory<any>;
26
26
  }
27
- // type QueryFactories<P extends string> = Record<CamelCase<`find_${P}`>, any>;
28
27
 
29
28
  type ServiceFactoryReturn<
30
29
  T extends AnyObject,
@@ -37,18 +36,62 @@ type ServiceFactoryReturn<
37
36
  };
38
37
 
39
38
  /**
40
- * Initial attempt to reduce the number of boiler plating done for directus services
39
+ * The shape of a provider creator a function that accepts a configured app
40
+ * and returns a bound provider. This matches createDirectusServiceProvider,
41
+ * createFetchServiceProvider, or any developer-supplied custom provider.
41
42
  */
42
- export function createServiceFactory<Schema extends Record<string, AnyObject>>(app: ConfiguredApplication) {
43
- const serviceProvider = createDirectusServiceProvider(app);
43
+ export type ProviderCreator = (
44
+ app: ConfiguredApplication,
45
+ ) => <T extends AnyObject>(
46
+ collection: string,
47
+ opt?: Partial<ServiceAbstractOptions> & Record<string, unknown>,
48
+ ) => ServiceAbstract<T>;
44
49
 
45
- // type K = keyof Schema;
50
+ /**
51
+ * Infers the concrete service instance type from a provider creator PC for a
52
+ * given row type T. Walks PC → ReturnType<PC> (bound provider) → infer R from
53
+ * its return → intersect with ServiceAbstract<T> to bind the concrete T slot.
54
+ *
55
+ * Examples:
56
+ * ExtractService<typeof createDirectusServiceProvider, Product> → DirectusBaseService<Product>
57
+ * ExtractService<typeof createFetchServiceProvider, Product> → BaseService<Product>
58
+ */
59
+ export type ExtractService<PC extends ProviderCreator, T extends AnyObject> =
60
+ ReturnType<PC> extends <_U extends AnyObject>(
61
+ collection: string,
62
+ opt?: any,
63
+ ) => infer R
64
+ ? R extends ServiceAbstract<AnyObject>
65
+ ? R & ServiceAbstract<T>
66
+ : ServiceAbstract<T>
67
+ : ServiceAbstract<T>;
46
68
 
47
- // function factory<T extends AnyObject> (collection: Px, opt?: Partial<ServiceAbstractOptions>): ServiceFactoryReturn<T, Px, DirectusBaseService<T>>;
48
- function factory<P extends string>(collection: P, opt?: Partial<ServiceAbstractOptions>): ServiceFactoryReturn<Schema[P], P, DirectusBaseService<Schema[P]>> {
49
- const service = serviceProvider<Schema[P]>(collection as string, opt);
69
+ /**
70
+ * Creates a per-schema service factory. Pass a provider creator as the second
71
+ * argument to choose the adapter backing (Directus or fetch). Defaults to
72
+ * createDirectusServiceProvider for full backward compatibility.
73
+ */
74
+ export function createServiceFactory<
75
+ Schema extends Record<string, AnyObject>,
76
+ PC extends ProviderCreator = typeof createDirectusServiceProvider,
77
+ >(
78
+ app: ConfiguredApplication,
79
+ providerCreator: PC = createDirectusServiceProvider as unknown as PC,
80
+ ) {
81
+ // Cast to ReturnType<PC> so the generic call below is typed against the
82
+ // concrete bound provider rather than the loose ProviderCreator upper bound.
83
+ const serviceProvider = providerCreator(app) as ReturnType<PC>;
84
+
85
+ function factory<P extends string>(
86
+ collection: P,
87
+ opt?: Partial<ServiceAbstractOptions>,
88
+ ): ServiceFactoryReturn<Schema[P], P, ExtractService<PC, Schema[P]>> {
89
+ // Keep as ServiceAbstract<Schema[P]> inside the body to avoid the deferred
90
+ // conditional type (ExtractService) distributing to a union, which would
91
+ // prevent passing service to createApiMutations. The ExtractService cast
92
+ // is applied only on the final return object.
93
+ const service = serviceProvider<Schema[P]>(collection, opt);
50
94
 
51
- // console.log(`FACTORY ${collection}`, service)
52
95
  const createQuries = () => createApiQueries<Schema[P]>({
53
96
  collection,
54
97
  service
@@ -56,21 +99,18 @@ export function createServiceFactory<Schema extends Record<string, AnyObject>>(a
56
99
 
57
100
  const createMutations = () => createApiMutations<Schema[P] & { id: string | number; }>({
58
101
  collection,
59
- service
102
+ // service is ServiceAbstract<Schema[P]>; cast to satisfy the id constraint
103
+ // required by createApiMutations. Safe: every concrete service impl handles
104
+ // id-bearing entities at runtime.
105
+ service: service as ServiceAbstract<Schema[P] & { id: string | number }>
60
106
  });
61
107
 
62
- // todo: Do the same for mutations
63
108
  const [find, findOne, findInfinite] = createQuries();
64
109
  const [createOne, updateOne, deleteOne] = createMutations();
65
110
 
66
- const k1: CamelCase<`find_${P}`> = camelCase(`find_${collection}`) as CamelCase<`find_${P}`>
67
- const k2: CamelCase<`find_${P}_by_id`> = camelCase(`find_${collection}_by_id`) as CamelCase<`find_${P}_by_id`>
68
- const k3: CamelCase<`find_${P}_infinite`> = camelCase(`find_${collection}_infinite`) as CamelCase<`find_${P}_infinite`>
69
- // const queries = {
70
- // [k1]: find,
71
- // [k2]: findOne,
72
- // [k3]: findInfinite,
73
- // };
111
+ const k1: CamelCase<`find_${P}`> = camelCase(`find_${collection}`) as CamelCase<`find_${P}`>;
112
+ const k2: CamelCase<`find_${P}_by_id`> = camelCase(`find_${collection}_by_id`) as CamelCase<`find_${P}_by_id`>;
113
+ const k3: CamelCase<`find_${P}_infinite`> = camelCase(`find_${collection}_infinite`) as CamelCase<`find_${P}_infinite`>;
74
114
 
75
115
  const m1: CamelCase<`create_${P}`> = camelCase(`create_${collection}`) as CamelCase<`create_${P}`>;
76
116
  const m2: CamelCase<`update_${P}`> = camelCase(`update_${collection}`) as CamelCase<`update_${P}`>;
@@ -86,8 +126,8 @@ export function createServiceFactory<Schema extends Record<string, AnyObject>>(a
86
126
  [m1]: createOne,
87
127
  [m2]: updateOne,
88
128
  [m3]: deleteOne,
89
- } as ServiceFactoryReturn<Schema[P], P, DirectusBaseService<Schema[P]>>;
129
+ } as ServiceFactoryReturn<Schema[P], P, ExtractService<PC, Schema[P]>>;
90
130
  }
91
131
 
92
132
  return factory;
93
- }
133
+ }
@@ -1,16 +1,16 @@
1
1
  import type { BaseMutationOpt, InfiniteQueryOpt, TMutationBase } from '../types/index.ts';
2
2
  import type { ApiPostQuery, ServiceAbstract } from '@tanglemedia/svelte-starter-core';
3
3
  import { createInsertMutation } from './mutation/create-insert-mutation.ts';
4
- import { createFindInfiniteQuery, type CreateFindInfiniteQueryOpt } from './query/create-find-infinite-query.svelte.ts';
4
+ import { createFindInfiniteQuery } from './query/create-find-infinite-query.svelte.ts';
5
5
  import { createFindOneQuery } from './query/create-find-one-query.svelte.ts';
6
6
  import { createFindQuery, getTotalCountQuery } from './query/create-find-query.svelte.ts';
7
7
  import { createBulkDeleteMutation, createDeleteMutation, createUpdateMutation } from './mutation';
8
8
 
9
- // export type CreateFindInfiniteQueryOpt<T extends object> = {
10
- // collection: string;
11
- // service: ServiceAbstract<T>;
12
- // options?: InfiniteQueryOpt;
13
- // };
9
+ export type CreateFindInfiniteQueryOpt<T extends object> = {
10
+ collection: string;
11
+ service: ServiceAbstract<T>;
12
+ options?: InfiniteQueryOpt;
13
+ };
14
14
 
15
15
  export type CreateMutationOpt<T extends TMutationBase> = {
16
16
  collection: string;