Skip to content

Commit 7fc5632

Browse files
Goosterhofclaude
andcommitted
feat: add @script-development/fs-adapter-store package
Reactive adapter-store pattern with domain state management, CRUD resource adapters, caching, and localStorage persistence. Uses reactive getter pattern for existing resources. Generic New<T> defaults to Omit<T, 'id'> — territories override. No case conversion — middleware handles snake/camel transformation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent dfc3afb commit 7fc5632

13 files changed

Lines changed: 2788 additions & 0 deletions

package-lock.json

Lines changed: 24 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
{
2+
"name": "@script-development/fs-adapter-store",
3+
"version": "0.1.0",
4+
"description": "Reactive adapter-store pattern with domain state management and CRUD resource adapters",
5+
"license": "UNLICENSED",
6+
"repository": {
7+
"type": "git",
8+
"url": "https://github.com/script-development/fs-packages.git",
9+
"directory": "packages/adapter-store"
10+
},
11+
"files": [
12+
"dist"
13+
],
14+
"type": "module",
15+
"main": "./dist/index.cjs",
16+
"module": "./dist/index.mjs",
17+
"types": "./dist/index.d.mts",
18+
"exports": {
19+
".": {
20+
"import": {
21+
"types": "./dist/index.d.mts",
22+
"default": "./dist/index.mjs"
23+
},
24+
"require": {
25+
"types": "./dist/index.d.cts",
26+
"default": "./dist/index.cjs"
27+
}
28+
}
29+
},
30+
"publishConfig": {
31+
"access": "public",
32+
"registry": "https://registry.npmjs.org"
33+
},
34+
"scripts": {
35+
"build": "tsdown",
36+
"typecheck": "tsc --noEmit",
37+
"lint:pkg": "publint && attw --pack",
38+
"test": "vitest run",
39+
"test:coverage": "vitest run --coverage"
40+
},
41+
"devDependencies": {
42+
"@script-development/fs-helpers": "^0.1.0",
43+
"@script-development/fs-http": "^0.1.0",
44+
"@script-development/fs-loading": "^0.1.0",
45+
"@script-development/fs-storage": "^0.1.0",
46+
"jsdom": "^29.0.1",
47+
"vue": "^3.5.0"
48+
},
49+
"peerDependencies": {
50+
"@script-development/fs-helpers": "^0.1.0",
51+
"@script-development/fs-http": "^0.1.0",
52+
"@script-development/fs-loading": "^0.1.0",
53+
"@script-development/fs-storage": "^0.1.0",
54+
"vue": "^3.5.0"
55+
}
56+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import type {
2+
Adapted,
3+
AdapterStoreConfig,
4+
AdapterStoreModule,
5+
Item,
6+
NewAdapted,
7+
StoreModuleForAdapter,
8+
} from "./types";
9+
import type { ComputedRef, Ref } from "vue";
10+
11+
import { computed, ref } from "vue";
12+
13+
import { EntryNotFoundError } from "./errors";
14+
15+
export const createAdapterStoreModule = <
16+
T extends Item,
17+
E extends Adapted<T, object> = Adapted<T>,
18+
N extends NewAdapted<T, object> = NewAdapted<T>,
19+
>(
20+
config: AdapterStoreConfig<T, E, N>,
21+
): StoreModuleForAdapter<T, E, N> => {
22+
const { domainName, adapter, httpService, storageService, loadingService } = config;
23+
24+
const storedItems = storageService.get<{ [id: number]: T }>(domainName, {});
25+
const frozenStoredItems = Object.fromEntries(
26+
Object.entries(storedItems).map(([id, item]) => [id, Object.freeze(item)]),
27+
) as { [id: number]: Readonly<T> };
28+
29+
const state: Ref<{ [id: number]: Readonly<T> }> = ref(frozenStoredItems);
30+
31+
const adaptedCache = new Map<number, E>();
32+
const getByIdComputedCache = new Map<number, ComputedRef<E | undefined>>();
33+
34+
const getAdapted = (item: Readonly<T>): E => {
35+
const cached = adaptedCache.get(item.id);
36+
if (cached) {
37+
return cached;
38+
}
39+
const adapted = adapter(storeModule, () => state.value[item.id] as T);
40+
adaptedCache.set(item.id, adapted);
41+
return adapted;
42+
};
43+
44+
const setById = (item: T): void => {
45+
state.value = { ...state.value, [item.id]: Object.freeze(item) };
46+
storageService.put(domainName, state.value);
47+
};
48+
49+
const deleteById = (id: number): void => {
50+
state.value = Object.fromEntries(
51+
Object.entries(state.value).filter(([key]) => Number(key) !== id),
52+
) as { [id: number]: Readonly<T> };
53+
storageService.put(domainName, state.value);
54+
adaptedCache.delete(id);
55+
getByIdComputedCache.delete(id);
56+
};
57+
58+
const storeModule: AdapterStoreModule<T> = { setById, deleteById };
59+
60+
const getById = (id: number): ComputedRef<E | undefined> => {
61+
const cached = getByIdComputedCache.get(id);
62+
if (cached) {
63+
return cached;
64+
}
65+
const computedRef = computed(() => (state.value[id] ? getAdapted(state.value[id]) : undefined));
66+
getByIdComputedCache.set(id, computedRef);
67+
return computedRef;
68+
};
69+
70+
return {
71+
getAll: computed(() => Object.values(state.value).map((item) => getAdapted(item))),
72+
getById,
73+
getOrFailById: async (id: number) => {
74+
await loadingService.ensureLoadingFinished();
75+
const item = getById(id).value;
76+
if (!item) throw new EntryNotFoundError(domainName, id);
77+
return item;
78+
},
79+
generateNew: () => adapter(storeModule),
80+
retrieveAll: async () => {
81+
const { data } = await httpService.getRequest<T[]>(domainName);
82+
state.value = data.reduce<{ [id: number]: Readonly<T> }>((acc, item) => {
83+
acc[item.id] = Object.freeze(item);
84+
return acc;
85+
}, {});
86+
storageService.put(domainName, state.value);
87+
adaptedCache.clear();
88+
getByIdComputedCache.clear();
89+
},
90+
};
91+
};
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
export class EntryNotFoundError extends Error {
2+
constructor(domainName: string, id: number) {
3+
super(`${domainName} with id ${id} not found`);
4+
this.name = "EntryNotFoundError";
5+
}
6+
}
7+
8+
export class MissingResponseDataError extends Error {
9+
constructor(message: string) {
10+
super(message);
11+
this.name = "MissingResponseDataError";
12+
}
13+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
export { createAdapterStoreModule } from "./adapter-store";
2+
export { resourceAdapter } from "./resource-adapter";
3+
export { EntryNotFoundError, MissingResponseDataError } from "./errors";
4+
export type {
5+
Item,
6+
DefaultNew,
7+
Adapted,
8+
NewAdapted,
9+
Adapter,
10+
AdapterStoreModule,
11+
AdapterStoreConfig,
12+
StoreModuleForAdapter,
13+
} from "./types";
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import type { HttpService } from "@script-development/fs-http";
2+
import type { Writable } from "@script-development/fs-helpers";
3+
import type { Ref } from "vue";
4+
5+
import type { Adapted, AdapterStoreModule, Item, NewAdapted } from "./types";
6+
7+
import { deepCopy } from "@script-development/fs-helpers";
8+
import { ref } from "vue";
9+
10+
import { MissingResponseDataError } from "./errors";
11+
12+
type ResourceHttpService = Pick<
13+
HttpService,
14+
"postRequest" | "putRequest" | "patchRequest" | "deleteRequest"
15+
>;
16+
17+
interface AdapterRepository<T extends Item, N> {
18+
create: (newItem: N) => Promise<T>;
19+
update: (id: number, updatedItem: N | T) => Promise<T>;
20+
patch: (id: number, partialItem: Partial<N>) => Promise<T>;
21+
delete: (id: number) => Promise<void>;
22+
}
23+
24+
const adapterRepositoryFactory = <T extends Item, N>(
25+
domainName: string,
26+
{ setById, deleteById }: AdapterStoreModule<T>,
27+
httpService: ResourceHttpService,
28+
): AdapterRepository<T, N> => {
29+
const dataHandler = (data: T | undefined, actionType: "create" | "update" | "patch"): T => {
30+
if (!data) {
31+
throw new MissingResponseDataError(
32+
`${actionType} route for ${domainName} returned no model in response to put in store.`,
33+
);
34+
}
35+
36+
setById(data);
37+
38+
return data;
39+
};
40+
41+
return {
42+
create: async (newItem: N) => {
43+
const { data } = await httpService.postRequest<T>(domainName, newItem);
44+
return dataHandler(data, "create");
45+
},
46+
update: async (id: number, updatedItem: N | T) => {
47+
const { data } = await httpService.putRequest<T>(`${domainName}/${id}`, updatedItem);
48+
return dataHandler(data, "update");
49+
},
50+
patch: async (id: number, partialItem: Partial<N>) => {
51+
const { data } = await httpService.patchRequest<T>(`${domainName}/${id}`, partialItem);
52+
return dataHandler(data, "patch");
53+
},
54+
delete: async (id: number) => {
55+
await httpService.deleteRequest<void>(`${domainName}/${id}`);
56+
deleteById(id);
57+
},
58+
};
59+
};
60+
61+
/**
62+
* Resource adapter factory — wraps a domain resource with mutable state and CRUD methods.
63+
*
64+
* Overloaded:
65+
* - With resourceGetter `() => T`: creates an Adapted (existing resource with update/patch/delete)
66+
* - Without: creates a NewAdapted (new resource with create)
67+
*/
68+
export function resourceAdapter<T extends Item, N extends object = Omit<T, "id">>(
69+
resourceGetter: () => T,
70+
domainName: string,
71+
storeModule: AdapterStoreModule<T>,
72+
httpService: ResourceHttpService,
73+
): Adapted<T, N>;
74+
export function resourceAdapter<T extends Item, N extends object = Omit<T, "id">>(
75+
resource: N,
76+
domainName: string,
77+
storeModule: AdapterStoreModule<T>,
78+
httpService: ResourceHttpService,
79+
): NewAdapted<T, N>;
80+
export function resourceAdapter<T extends Item, N extends object = Omit<T, "id">>(
81+
resource: (() => T) | N,
82+
domainName: string,
83+
storeModule: AdapterStoreModule<T>,
84+
httpService: ResourceHttpService,
85+
): Adapted<T, N> | NewAdapted<T, N> {
86+
const repository = adapterRepositoryFactory<T, N>(domainName, storeModule, httpService);
87+
88+
if (typeof resource === "function") {
89+
const resourceGetter = resource as () => T;
90+
const mutable = ref(deepCopy(resourceGetter())) as Ref<Writable<T>>;
91+
92+
const adapted = {} as Adapted<T, N>;
93+
const source = resourceGetter();
94+
95+
for (const key of Object.keys(source)) {
96+
Object.defineProperty(adapted, key, {
97+
get: () => resourceGetter()[key as keyof T],
98+
enumerable: true,
99+
configurable: true,
100+
});
101+
}
102+
103+
Object.defineProperty(adapted, "mutable", {
104+
value: mutable,
105+
enumerable: true,
106+
configurable: true,
107+
writable: false,
108+
});
109+
Object.defineProperty(adapted, "reset", {
110+
value: () => (mutable.value = deepCopy(resourceGetter())),
111+
enumerable: true,
112+
configurable: true,
113+
writable: false,
114+
});
115+
Object.defineProperty(adapted, "update", {
116+
value: () => repository.update(resourceGetter().id, mutable.value as N | T),
117+
enumerable: true,
118+
configurable: true,
119+
writable: false,
120+
});
121+
Object.defineProperty(adapted, "patch", {
122+
value: (partialItem: Partial<N>) => repository.patch(resourceGetter().id, partialItem),
123+
enumerable: true,
124+
configurable: true,
125+
writable: false,
126+
});
127+
Object.defineProperty(adapted, "delete", {
128+
value: () => repository.delete(resourceGetter().id),
129+
enumerable: true,
130+
configurable: true,
131+
writable: false,
132+
});
133+
134+
return adapted;
135+
}
136+
137+
const mutable = ref(deepCopy(resource)) as Ref<Writable<N>>;
138+
139+
return {
140+
...Object.freeze(resource as object),
141+
mutable,
142+
reset: () => (mutable.value = deepCopy(resource)),
143+
create: () => repository.create(mutable.value as N),
144+
} as unknown as NewAdapted<T, N>;
145+
}

0 commit comments

Comments
 (0)