@svadmin/core 0.50.0 → 0.52.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/package.json +21 -4
- package/src/access-control-contract.ts +250 -0
- package/src/adapters/casbin.ts +20 -14
- package/src/adapters/casl.ts +22 -19
- package/src/admin-tool.ts +7 -7
- package/src/audit-contract.ts +105 -0
- package/src/audit.ts +88 -128
- package/src/auth-hooks.svelte.ts +593 -261
- package/src/auth-mutation-contract.ts +143 -0
- package/src/auth-query-contract.ts +97 -0
- package/src/captured-mutation.svelte.ts +45 -0
- package/src/chatProvider.svelte.ts +1 -1
- package/src/command-contract.ts +195 -0
- package/src/context.svelte.ts +217 -205
- package/src/contract-context.svelte.ts +37 -0
- package/src/contract-form.svelte.ts +566 -0
- package/src/create-contract.ts +56 -0
- package/src/create-hooks.svelte.ts +347 -0
- package/src/create-many-contract.ts +90 -0
- package/src/create-many-hooks.svelte.ts +396 -0
- package/src/data-transfer.svelte.ts +1 -215
- package/src/defined-options.ts +50 -0
- package/src/delete-contract.ts +50 -0
- package/src/delete-hooks.svelte.ts +403 -0
- package/src/delete-many-contract.ts +60 -0
- package/src/delete-many-hooks.svelte.ts +402 -0
- package/src/enterprise.ts +4 -4
- package/src/export-format.ts +16 -14
- package/src/export-hooks.svelte.ts +191 -0
- package/src/feature-gate-contract.ts +53 -0
- package/src/hook-utils.svelte.ts +53 -105
- package/src/hooks.svelte.ts +19 -776
- package/src/http.ts +88 -87
- package/src/i18n.svelte.ts +56 -2
- package/src/import-contract.ts +106 -0
- package/src/import-hooks.svelte.ts +310 -0
- package/src/index.ts +77 -30
- package/src/inferencer-graphql.ts +7 -6
- package/src/infinite-query.svelte.ts +228 -0
- package/src/invalidation-contract.ts +26 -0
- package/src/live-sse.ts +91 -127
- package/src/live-subscription.svelte.ts +130 -0
- package/src/live-transport.ts +127 -0
- package/src/live-websocket.ts +85 -118
- package/src/live.svelte.ts +66 -68
- package/src/notification.svelte.ts +17 -15
- package/src/permission-catalog.ts +121 -0
- package/src/permission-hints-contract.ts +48 -0
- package/src/permissions.svelte.ts +112 -59
- package/src/plain-data.ts +61 -0
- package/src/provider-bundle.ts +85 -79
- package/src/provider-response.ts +35 -0
- package/src/query-hooks.svelte.ts +241 -288
- package/src/query-invalidation.ts +172 -0
- package/src/query-keys.ts +221 -110
- package/src/query-session.svelte.ts +216 -0
- package/src/query-snapshot.ts +144 -0
- package/src/reactive-projection.ts +53 -0
- package/src/record-decoder.ts +63 -0
- package/src/resource-binding.svelte.ts +19 -0
- package/src/resource-contract.ts +595 -0
- package/src/resource-mutation-types.ts +30 -0
- package/src/resource-schemas.ts +371 -0
- package/src/resource-transport-config.ts +156 -0
- package/src/route-parsing.ts +11 -11
- package/src/router-provider.ts +64 -59
- package/src/router.ts +8 -3
- package/src/routing-hooks.svelte.ts +90 -62
- package/src/schema-validation.ts +92 -0
- package/src/schema.ts +13 -0
- package/src/select-options.ts +95 -0
- package/src/select-query.svelte.ts +191 -0
- package/src/session-query.svelte.ts +88 -0
- package/src/strict-command-hooks.svelte.ts +333 -0
- package/src/strict-hooks.svelte.ts +198 -0
- package/src/table-hooks.svelte.ts +180 -174
- package/src/table-state.svelte.ts +117 -117
- package/src/task-contract.ts +87 -0
- package/src/task-hooks.svelte.ts +193 -149
- package/src/task-provider.ts +115 -0
- package/src/task-subscription.ts +132 -0
- package/src/theme.svelte.ts +155 -154
- package/src/toast.svelte.ts +38 -37
- package/src/types.ts +55 -108
- package/src/unsafe.ts +7 -0
- package/src/update-contract.ts +65 -0
- package/src/update-hooks.svelte.ts +347 -0
- package/src/update-many-contract.ts +79 -0
- package/src/update-many-hooks.svelte.ts +373 -0
- package/src/url-sync.ts +11 -10
- package/src/useCan.ts +96 -31
- package/src/useParsed.svelte.ts +18 -14
- package/src/useStepsForm.svelte.ts +123 -63
- package/src/utility-hooks.svelte.ts +101 -158
- package/src/form-hooks.svelte.ts +0 -889
- package/src/mutation-hooks.svelte.ts +0 -561
- package/src/schema-form.ts +0 -31
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { QueryClient } from '@tanstack/svelte-query';
|
|
2
|
+
import { dataQueryMatches, parseQueryKey, type DataQueryMatcher, type QueryDescriptorData } from './query-keys';
|
|
3
|
+
import { snapshotPlainData } from './plain-data';
|
|
4
|
+
import { snapshotInvalidationParams } from './invalidation-contract';
|
|
5
|
+
import { definedOptions } from './defined-options';
|
|
6
|
+
import { HttpError } from './types';
|
|
7
|
+
import { Type } from '@sinclair/typebox';
|
|
8
|
+
import { checkExact } from './schema-validation';
|
|
9
|
+
|
|
10
|
+
export interface QueryCacheOwner {
|
|
11
|
+
readonly source: string;
|
|
12
|
+
readonly authSession: string;
|
|
13
|
+
}
|
|
14
|
+
export type RefreshScope = Exclude<NonNullable<ReturnType<typeof snapshotInvalidationParams>['invalidates']>, false | 'all'>[number];
|
|
15
|
+
const ownerSchema = Type.Object({
|
|
16
|
+
source: Type.String({ minLength: 1 }), authSession: Type.String({ minLength: 1 }),
|
|
17
|
+
}, { additionalProperties: false });
|
|
18
|
+
const matcherSchema = Type.Object({
|
|
19
|
+
resource: Type.String({ minLength: 1 }),
|
|
20
|
+
provider: Type.Optional(Type.String({ minLength: 1 })),
|
|
21
|
+
contract: Type.Optional(Type.String({ minLength: 1 })),
|
|
22
|
+
tenant: Type.Optional(Type.Union([Type.String(), Type.Number()])),
|
|
23
|
+
id: Type.Optional(Type.Union([Type.String(), Type.Number()])),
|
|
24
|
+
action: Type.Optional(Type.Union([
|
|
25
|
+
Type.Literal('list'), Type.Literal('infiniteList'), Type.Literal('select'),
|
|
26
|
+
Type.Literal('selectDefaults'), Type.Literal('many'), Type.Literal('one'),
|
|
27
|
+
])),
|
|
28
|
+
method: Type.Optional(Type.String()),
|
|
29
|
+
}, { additionalProperties: false });
|
|
30
|
+
|
|
31
|
+
function invalidRefresh(): never {
|
|
32
|
+
throw new HttpError('Invalid refresh request', 422, undefined, { code: 'INVALID_REFRESH_REQUEST' });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Copy own data properties while leaving executable capabilities opaque. */
|
|
36
|
+
function properties(value: unknown, allowed: readonly string[]): Map<string, unknown> {
|
|
37
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return invalidRefresh();
|
|
38
|
+
const prototype: unknown = Object.getPrototypeOf(value);
|
|
39
|
+
if ((prototype !== Object.prototype && prototype !== null) || Object.getOwnPropertySymbols(value).length) return invalidRefresh();
|
|
40
|
+
const result = new Map<string, unknown>();
|
|
41
|
+
for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(value))) {
|
|
42
|
+
if (!allowed.includes(key) || !descriptor.enumerable || !('value' in descriptor)) return invalidRefresh();
|
|
43
|
+
const field: unknown = descriptor.value;
|
|
44
|
+
result.set(key, field);
|
|
45
|
+
}
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function snapshotMatcher(value: unknown): DataQueryMatcher {
|
|
50
|
+
const fields = properties(value, ['resource', 'provider', 'contract', 'tenant', 'id', 'action', 'method']);
|
|
51
|
+
const candidate = snapshotPlainData(Object.fromEntries([...fields].filter(([, value]) => value !== undefined)));
|
|
52
|
+
if (!checkExact(matcherSchema, candidate)) return invalidRefresh();
|
|
53
|
+
// Missing tenancy selects an unset tenant; it is not permission to cross tenants.
|
|
54
|
+
return Object.freeze({
|
|
55
|
+
...candidate, provider: candidate.provider ?? 'default', tenant: candidate.tenant, contract: candidate.contract,
|
|
56
|
+
...(fields.has('id') ? { id: candidate.id } : {}),
|
|
57
|
+
...(fields.has('action') ? { action: candidate.action } : {}),
|
|
58
|
+
...(fields.has('method') ? { method: candidate.method } : {}),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function snapshotOwner(value: unknown): QueryCacheOwner {
|
|
63
|
+
const owner = snapshotPlainData(value);
|
|
64
|
+
if (!checkExact(ownerSchema, owner)) return invalidRefresh();
|
|
65
|
+
return Object.freeze(owner);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function readCapturedQuery(
|
|
69
|
+
key: readonly unknown[], matcher: DataQueryMatcher, owner: QueryCacheOwner,
|
|
70
|
+
): QueryDescriptorData | undefined {
|
|
71
|
+
try {
|
|
72
|
+
const descriptor = parseQueryKey(snapshotPlainData(key));
|
|
73
|
+
if (!descriptor || descriptor.kind !== 'data' || !dataQueryMatches([descriptor], matcher)) return undefined;
|
|
74
|
+
const params = descriptor.params;
|
|
75
|
+
if (typeof params !== 'object' || params === null || Array.isArray(params)) return undefined;
|
|
76
|
+
const source: unknown = Object.getOwnPropertyDescriptor(params, 'source')?.value;
|
|
77
|
+
const session: unknown = Object.getOwnPropertyDescriptor(params, 'authSession')?.value;
|
|
78
|
+
return source === owner.source && session === owner.authSession ? descriptor : undefined;
|
|
79
|
+
} catch { return undefined; }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Untagged, malformed and accessor-backed keys never acquire an inferred owner. */
|
|
83
|
+
export function readOwnedDataQuery(
|
|
84
|
+
key: readonly unknown[], matcher: DataQueryMatcher, owner: QueryCacheOwner,
|
|
85
|
+
): QueryDescriptorData | undefined {
|
|
86
|
+
try {
|
|
87
|
+
return readCapturedQuery(key, snapshotMatcher(matcher), snapshotOwner(owner));
|
|
88
|
+
} catch {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function refreshSuperseded(): HttpError {
|
|
94
|
+
return new HttpError('Refresh is no longer current', 409, undefined, { code: 'REFRESH_SUPERSEDED' });
|
|
95
|
+
}
|
|
96
|
+
function refreshFailed(): HttpError {
|
|
97
|
+
return new HttpError('Refresh failed', 500, undefined, { code: 'REFRESH_FAILED' });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function captureRequest(value: unknown) {
|
|
101
|
+
try {
|
|
102
|
+
const fields = properties(value, ['client', 'matcher', 'owner', 'scopes', 'id', 'current']);
|
|
103
|
+
const client = fields.get('client');
|
|
104
|
+
const callback = fields.get('current');
|
|
105
|
+
if (!(client instanceof QueryClient) || typeof callback !== 'function') return invalidRefresh();
|
|
106
|
+
const matcher = snapshotMatcher(fields.get('matcher'));
|
|
107
|
+
const owner = snapshotOwner(fields.get('owner'));
|
|
108
|
+
const params = snapshotInvalidationParams({
|
|
109
|
+
invalidates: fields.get('scopes'), ...(fields.has('id') ? { id: fields.get('id') } : {}),
|
|
110
|
+
});
|
|
111
|
+
if (!Array.isArray(params.invalidates)) return invalidRefresh();
|
|
112
|
+
let retired = false;
|
|
113
|
+
return {
|
|
114
|
+
client, matcher, owner, scopes: params.invalidates, ...definedOptions({ id: params.id }),
|
|
115
|
+
current(): boolean {
|
|
116
|
+
if (retired) return false;
|
|
117
|
+
try {
|
|
118
|
+
const result: unknown = Reflect.apply(callback, undefined, []);
|
|
119
|
+
if (result !== true) {
|
|
120
|
+
retired = true;
|
|
121
|
+
if (typeof result !== 'boolean') void Promise.resolve(result).catch(() => {});
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
return true;
|
|
125
|
+
} catch {
|
|
126
|
+
retired = true;
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
} catch { return invalidRefresh(); }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Recheck ownership for each dispatch and await every started request, including failures. */
|
|
135
|
+
export async function invalidateOwnedQueries(request: {
|
|
136
|
+
client: QueryClient;
|
|
137
|
+
matcher: DataQueryMatcher;
|
|
138
|
+
owner: QueryCacheOwner;
|
|
139
|
+
scopes: readonly RefreshScope[];
|
|
140
|
+
id?: string | number;
|
|
141
|
+
current: () => boolean;
|
|
142
|
+
}): Promise<void> {
|
|
143
|
+
const { client, matcher, owner, scopes, id, current } = captureRequest(request);
|
|
144
|
+
if (!current()) throw refreshSuperseded();
|
|
145
|
+
const matches = (key: readonly unknown[]) => {
|
|
146
|
+
if (!current()) return false;
|
|
147
|
+
const descriptor = readCapturedQuery(key, matcher, owner);
|
|
148
|
+
if (!descriptor) return false;
|
|
149
|
+
return scopes.some(scope => {
|
|
150
|
+
if (scope === 'resourceAll') return true;
|
|
151
|
+
if (scope === 'list') return ['list', 'infiniteList', 'select', 'selectDefaults'].includes(descriptor.action);
|
|
152
|
+
if (scope === 'many') return descriptor.action === 'many';
|
|
153
|
+
return scope === 'detail' && descriptor.action === 'one' && (id === undefined || descriptor.id === id);
|
|
154
|
+
});
|
|
155
|
+
};
|
|
156
|
+
const selected = (() => {
|
|
157
|
+
try {
|
|
158
|
+
return client.getQueryCache().findAll({ predicate: query => matches(query.queryKey) });
|
|
159
|
+
} catch {
|
|
160
|
+
throw current() ? refreshFailed() : refreshSuperseded();
|
|
161
|
+
}
|
|
162
|
+
})();
|
|
163
|
+
const tasks = selected.map(async query => {
|
|
164
|
+
if (!current()) return;
|
|
165
|
+
await client.invalidateQueries({
|
|
166
|
+
predicate: candidate => candidate === query && matches(candidate.queryKey),
|
|
167
|
+
}, { throwOnError: true });
|
|
168
|
+
});
|
|
169
|
+
const settled = await Promise.allSettled(tasks);
|
|
170
|
+
if (!current()) throw refreshSuperseded();
|
|
171
|
+
if (settled.some(result => result.status === 'rejected')) throw refreshFailed();
|
|
172
|
+
}
|
package/src/query-keys.ts
CHANGED
|
@@ -1,127 +1,238 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
type BaseDescriptor = {
|
|
10
|
-
readonly namespace: typeof NAMESPACE;
|
|
11
|
-
readonly version: typeof VERSION;
|
|
12
|
-
readonly provider: string;
|
|
13
|
-
readonly tenant?: TenantId;
|
|
14
|
-
readonly resource?: string;
|
|
15
|
-
readonly id?: string | number;
|
|
16
|
-
readonly params?: unknown;
|
|
17
|
-
readonly method?: string;
|
|
18
|
-
};
|
|
1
|
+
import { Type,type Static } from '@sinclair/typebox';
|
|
2
|
+
import { checkExact } from './schema-validation';
|
|
3
|
+
import { definedOptions } from './defined-options';
|
|
4
|
+
import { snapshotPlainData, type JsonValue } from './plain-data';
|
|
5
|
+
import { HttpError } from './types';
|
|
6
|
+
|
|
7
|
+
export const NAMESPACE='svadmin';
|
|
8
|
+
export const VERSION=2;
|
|
19
9
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
10
|
+
const baseProperties={
|
|
11
|
+
namespace: Type.Literal(NAMESPACE),
|
|
12
|
+
version: Type.Literal(VERSION),
|
|
13
|
+
provider: Type.String({ minLength: 1 }),
|
|
14
|
+
contract: Type.Optional(Type.String({ minLength: 1 })),
|
|
15
|
+
tenant: Type.Optional(Type.Union([Type.String(),Type.Number()])),
|
|
16
|
+
resource: Type.Optional(Type.String()),
|
|
17
|
+
id: Type.Optional(Type.Union([Type.String(),Type.Number()])),
|
|
18
|
+
params: Type.Optional(Type.Unknown()),
|
|
19
|
+
method: Type.Optional(Type.String()),
|
|
20
|
+
};
|
|
21
|
+
const identity=Type.Union([Type.String(),Type.Number()]);
|
|
22
|
+
const dataBase={
|
|
23
|
+
...baseProperties,
|
|
24
|
+
kind: Type.Literal('data'),
|
|
25
|
+
resource: Type.String(),
|
|
35
26
|
};
|
|
27
|
+
const dataSchema=Type.Union([Type.Object({
|
|
28
|
+
...dataBase,action: Type.Literal('one'),id: identity,
|
|
29
|
+
},{ additionalProperties: false }),Type.Object({
|
|
30
|
+
...dataBase,
|
|
31
|
+
action: Type.Union([
|
|
32
|
+
Type.Literal('list'),Type.Literal('infiniteList'),
|
|
33
|
+
Type.Literal('many'),Type.Literal('select'),Type.Literal('selectDefaults'),
|
|
34
|
+
]),
|
|
35
|
+
},{ additionalProperties: false })]);
|
|
36
|
+
const taskSchema=Type.Union([Type.Object({
|
|
37
|
+
...baseProperties,
|
|
38
|
+
kind: Type.Literal('task'),action: Type.Literal('one'),id: identity,
|
|
39
|
+
},{ additionalProperties: false }),Type.Object({
|
|
40
|
+
...baseProperties,kind: Type.Literal('task'),action: Type.Literal('list'),
|
|
41
|
+
},{ additionalProperties: false })]);
|
|
42
|
+
const accessSchema=Type.Object({
|
|
43
|
+
...baseProperties,kind: Type.Literal('access'),action: Type.Optional(Type.Literal('can')),
|
|
44
|
+
},{ additionalProperties: false });
|
|
45
|
+
const customSchema=Type.Object({
|
|
46
|
+
...baseProperties,kind: Type.Literal('custom'),action: Type.Optional(Type.String()),
|
|
47
|
+
resource: Type.String(),id: identity,
|
|
48
|
+
},{ additionalProperties: false });
|
|
49
|
+
const keySchema=Type.Tuple([Type.Union([dataSchema,taskSchema,accessSchema,customSchema])]);
|
|
50
|
+
|
|
51
|
+
export type QueryDescriptorData=Readonly<Static<typeof dataSchema>>;
|
|
52
|
+
export type QueryDescriptorTask=Readonly<Static<typeof taskSchema>>;
|
|
53
|
+
export type QueryDescriptorAccess=Readonly<Static<typeof accessSchema>>;
|
|
54
|
+
export type QueryDescriptorCustom=Readonly<Static<typeof customSchema>>;
|
|
55
|
+
export type QueryDescriptor=QueryDescriptorData|QueryDescriptorTask|QueryDescriptorAccess|QueryDescriptorCustom;
|
|
56
|
+
export type QueryKey=readonly [descriptor: QueryDescriptor];
|
|
57
|
+
export type QueryKind=QueryDescriptor['kind'];
|
|
58
|
+
export type QueryDataAction=QueryDescriptorData['action'];
|
|
59
|
+
export type QueryTaskAction=QueryDescriptorTask['action'];
|
|
60
|
+
|
|
61
|
+
export type QueryKeysContext=Partial<Pick<QueryDescriptorCustom,'provider'|'contract'|'tenant'|'resource'|'id'|'params'|'method'>>;
|
|
62
|
+
|
|
63
|
+
// An absent matcher field is a wildcard; explicit undefined matches an unset field.
|
|
64
|
+
// The provider is special: explicit undefined selects the default provider.
|
|
65
|
+
export type QueryMatcher={
|
|
66
|
+
readonly [K in keyof Omit<QueryDescriptorCustom,'namespace'|'version'|'params'|'kind'>]?: QueryDescriptorCustom[K]|undefined;
|
|
67
|
+
}&{ readonly kind?: QueryKind };
|
|
68
|
+
export type DataQueryMatcher=Omit<QueryMatcher,'kind'|'resource'>&{ readonly resource: string };
|
|
69
|
+
export type QueryKeysBuilder=Readonly<{
|
|
70
|
+
data: Readonly<{
|
|
71
|
+
list(resource: string,params?: unknown): QueryKey;
|
|
72
|
+
infiniteList(resource: string,params?: unknown): QueryKey;
|
|
73
|
+
one(resource: string,id: string|number,params?: unknown): QueryKey;
|
|
74
|
+
many(resource: string,params: unknown): QueryKey;
|
|
75
|
+
select(resource: string,params?: unknown): QueryKey;
|
|
76
|
+
selectDefaults(resource: string,params?: unknown): QueryKey;
|
|
77
|
+
}>;
|
|
78
|
+
task: Readonly<{ list(params?: unknown): QueryKey; one(id: string|number): QueryKey }>;
|
|
79
|
+
access: Readonly<{ can(resource?: string,params?: unknown): QueryKey }>;
|
|
80
|
+
custom: Readonly<{ call(resource: string,id: string|number,method?: string,params?: unknown): QueryKey }>;
|
|
81
|
+
}>;
|
|
82
|
+
|
|
83
|
+
const contextSchema=Type.Object({
|
|
84
|
+
provider: Type.Optional(baseProperties.provider),contract: baseProperties.contract,tenant: baseProperties.tenant,
|
|
85
|
+
resource: baseProperties.resource,id: baseProperties.id,params: baseProperties.params,method: baseProperties.method,
|
|
86
|
+
},{ additionalProperties: false });
|
|
87
|
+
const matcherSchema=Type.Object({
|
|
88
|
+
provider: Type.Optional(baseProperties.provider),contract: baseProperties.contract,tenant: baseProperties.tenant,
|
|
89
|
+
resource: baseProperties.resource,id: baseProperties.id,method: baseProperties.method,
|
|
90
|
+
action: Type.Optional(Type.String()),
|
|
91
|
+
kind: Type.Optional(Type.Union([Type.Literal('data'),Type.Literal('task'),Type.Literal('access'),Type.Literal('custom')])),
|
|
92
|
+
},{ additionalProperties: false });
|
|
93
|
+
|
|
94
|
+
function invalidKey(): never {
|
|
95
|
+
throw new HttpError('Invalid query key',422,undefined,{ code: 'INVALID_QUERY_KEY' });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function ownFields(value: unknown, allowed: readonly string[]): [string,unknown][] {
|
|
99
|
+
if(typeof value!=='object'||value===null||Array.isArray(value)) return invalidKey();
|
|
100
|
+
const prototype: unknown=Object.getPrototypeOf(value);
|
|
101
|
+
if((prototype!==Object.prototype&&prototype!==null)||Object.getOwnPropertySymbols(value).length) return invalidKey();
|
|
102
|
+
return Object.entries(Object.getOwnPropertyDescriptors(value)).map(([key,descriptor]) => {
|
|
103
|
+
if(!allowed.includes(key)||!descriptor.enumerable||!('value' in descriptor)) return invalidKey();
|
|
104
|
+
const field: unknown=descriptor.value;
|
|
105
|
+
return [key,field];
|
|
106
|
+
});
|
|
107
|
+
}
|
|
36
108
|
|
|
37
|
-
function
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
function makeDescriptor(value: { kind: 'task'; provider?: string; tenant?: TenantId; resource?: string; action: QueryTaskAction; id?: string | number; params?: unknown; method?: string; }): QueryDescriptorTask;
|
|
42
|
-
function makeDescriptor(value: { kind: 'access'; provider?: string; tenant?: TenantId; resource?: string; action?: 'can'; id?: string | number; params?: unknown; method?: string; }): QueryDescriptorAccess;
|
|
43
|
-
function makeDescriptor(value: { kind: 'custom'; provider?: string; tenant?: TenantId; resource?: string; id?: string | number; action?: string; params?: unknown; method?: string; }): QueryDescriptorCustom;
|
|
44
|
-
function makeDescriptor(value: { kind: QueryKind; provider?: string; tenant?: TenantId; resource?: string; action?: string; id?: string | number; params?: unknown; method?: string; }): QueryDescriptor {
|
|
45
|
-
const provider = withDefaultProvider(value.provider);
|
|
46
|
-
assertProvider(provider);
|
|
47
|
-
|
|
48
|
-
const base = { namespace: NAMESPACE, version: VERSION, provider, tenant: value.tenant, resource: value.resource, id: value.id, params: value.params, method: value.method } as const;
|
|
49
|
-
if (value.kind === 'data') return { ...base, kind: 'data', action: value.action } as QueryDescriptorData;
|
|
50
|
-
if (value.kind === 'task') return { ...base, kind: 'task', action: value.action as QueryTaskAction } as QueryDescriptorTask;
|
|
51
|
-
if (value.kind === 'access') return { ...base, kind: 'access', action: value.action as 'can' | undefined } as QueryDescriptorAccess;
|
|
52
|
-
return { ...base, kind: 'custom', action: value.action } as QueryDescriptorCustom;
|
|
109
|
+
function freezeData(value: JsonValue): void {
|
|
110
|
+
if(typeof value!=='object'||value===null) return;
|
|
111
|
+
for(const field of Object.values(value)) freezeData(field);
|
|
112
|
+
Object.freeze(value);
|
|
53
113
|
}
|
|
54
114
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
115
|
+
function snapshotKey(value: unknown): QueryKey|undefined {
|
|
116
|
+
try {
|
|
117
|
+
const candidate=snapshotPlainData(value);
|
|
118
|
+
freezeData(candidate);
|
|
119
|
+
return checkExact(keySchema,candidate)? candidate:undefined;
|
|
120
|
+
} catch { return undefined; }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function build(descriptor: Record<string,unknown>): QueryKey {
|
|
124
|
+
const key=snapshotKey([definedOptions(descriptor)]);
|
|
125
|
+
return key??invalidKey();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function snapshotContext(value: unknown): QueryKeysContext {
|
|
129
|
+
try {
|
|
130
|
+
const fields=ownFields(value,['provider','contract','tenant','resource','id','params','method']);
|
|
131
|
+
const candidate=snapshotPlainData(Object.fromEntries(fields.filter(([key,value]) => key!=='params'||value!==undefined)));
|
|
132
|
+
freezeData(candidate);
|
|
133
|
+
if(checkExact(contextSchema,candidate)) return candidate;
|
|
134
|
+
} catch { /* Never expose rejected key data or reflection errors. */ }
|
|
135
|
+
return invalidKey();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function snapshotMatcher(value: unknown): QueryMatcher|undefined {
|
|
139
|
+
try {
|
|
140
|
+
const fields=ownFields(value,['provider','contract','tenant','resource','id','action','method','kind']);
|
|
141
|
+
if(fields.some(([key,value]) => key==='kind'&&value===undefined)) return undefined;
|
|
142
|
+
const candidate=snapshotPlainData(Object.fromEntries(fields.filter(([,value]) => value!==undefined)));
|
|
143
|
+
if(!checkExact(matcherSchema,candidate)) return undefined;
|
|
144
|
+
const explicitUndefined=Object.fromEntries<undefined>(fields.filter(([,value]) => value===undefined).map(([key]) => [key,undefined]));
|
|
145
|
+
return Object.freeze({ ...candidate,...explicitUndefined });
|
|
146
|
+
} catch { return undefined; }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function keys(context: QueryKeysContext={}): QueryKeysBuilder {
|
|
150
|
+
const captured=snapshotContext(context);
|
|
151
|
+
const provider=captured.provider??'default';
|
|
152
|
+
const base=definedOptions({
|
|
153
|
+
namespace: NAMESPACE,version: VERSION,provider,
|
|
154
|
+
contract: captured.contract,tenant: captured.tenant,resource: captured.resource,
|
|
155
|
+
id: captured.id,params: captured.params,method: captured.method,
|
|
156
|
+
});
|
|
157
|
+
const result: QueryKeysBuilder={
|
|
158
|
+
data: Object.freeze({
|
|
159
|
+
list: (resource,params=base.params) => build({ ...base,kind: 'data',action: 'list',resource,params }),
|
|
160
|
+
infiniteList: (resource,params=base.params) => build({ ...base,kind: 'data',action: 'infiniteList',resource,params }),
|
|
161
|
+
one: (resource,id,params=base.params) => build({ ...base,kind: 'data',action: 'one',resource,id,params }),
|
|
162
|
+
many: (resource,params) => build({
|
|
163
|
+
...base,
|
|
164
|
+
kind: 'data',
|
|
165
|
+
action: 'many',
|
|
166
|
+
resource,
|
|
167
|
+
id: undefined,
|
|
168
|
+
params,
|
|
169
|
+
}),
|
|
170
|
+
select: (resource,params=base.params) => build({ ...base,kind: 'data',action: 'select',resource,params }),
|
|
171
|
+
selectDefaults: (resource,params=base.params) => build({ ...base,kind: 'data',action: 'selectDefaults',resource,params }),
|
|
172
|
+
}),
|
|
173
|
+
task: Object.freeze({
|
|
174
|
+
list: (params=base.params) => build({ ...base,kind: 'task',action: 'list',params }),
|
|
175
|
+
one: id => build({
|
|
176
|
+
...base,
|
|
177
|
+
kind: 'task',
|
|
178
|
+
action: 'one',
|
|
179
|
+
id,
|
|
180
|
+
params: undefined,
|
|
181
|
+
}),
|
|
182
|
+
}),
|
|
183
|
+
access: Object.freeze({
|
|
184
|
+
can: (resource=base.resource,params=base.params) => build({
|
|
185
|
+
...base,
|
|
186
|
+
kind: 'access',
|
|
187
|
+
action: 'can',
|
|
188
|
+
resource,
|
|
189
|
+
params,
|
|
190
|
+
})
|
|
191
|
+
}),
|
|
192
|
+
custom: Object.freeze({
|
|
193
|
+
call: (resource,id,method,params) => build({
|
|
194
|
+
...base,
|
|
195
|
+
kind: 'custom',
|
|
196
|
+
action: 'call',
|
|
197
|
+
resource,
|
|
198
|
+
id,
|
|
199
|
+
method,
|
|
200
|
+
params,
|
|
201
|
+
})
|
|
202
|
+
}),
|
|
72
203
|
};
|
|
204
|
+
return Object.freeze(result);
|
|
73
205
|
}
|
|
74
206
|
|
|
75
|
-
export const queryKeys: QueryKeysBuilder
|
|
76
|
-
data: {
|
|
77
|
-
list: (resource, params) => keys().data.list(resource, params),
|
|
78
|
-
infiniteList: (resource, params) => keys().data.infiniteList(resource, params),
|
|
79
|
-
one: (resource, id, params) => keys().data.one(resource, id, params),
|
|
80
|
-
many: (resource, params) => keys().data.many(resource, params),
|
|
81
|
-
select: (resource, params) => keys().data.select(resource, params),
|
|
82
|
-
selectDefaults: (resource, params) => keys().data.selectDefaults(resource, params),
|
|
83
|
-
},
|
|
84
|
-
task: { list: (params) => keys().task.list(params), one: (id) => keys().task.one(id) },
|
|
85
|
-
access: { can: (resource, params) => keys().access.can(resource, params) },
|
|
86
|
-
custom: { call: (resource, id, method, params) => keys().custom.call(resource, id, method, params) },
|
|
87
|
-
};
|
|
207
|
+
export const queryKeys: QueryKeysBuilder=keys();
|
|
88
208
|
|
|
89
|
-
export function parseQueryKey(value: unknown): QueryDescriptor
|
|
90
|
-
|
|
91
|
-
const candidate = value[0];
|
|
92
|
-
if (!candidate || typeof candidate !== 'object') return;
|
|
93
|
-
const descriptor = candidate as QueryDescriptor;
|
|
94
|
-
if (descriptor.namespace !== NAMESPACE || descriptor.version !== VERSION || typeof descriptor.provider !== 'string' || !descriptor.provider.length) return;
|
|
95
|
-
if (descriptor.tenant !== undefined && typeof descriptor.tenant !== 'string' && typeof descriptor.tenant !== 'number') return;
|
|
96
|
-
if (descriptor.resource !== undefined && typeof descriptor.resource !== 'string') return;
|
|
97
|
-
if (descriptor.id !== undefined && typeof descriptor.id !== 'string' && typeof descriptor.id !== 'number') return;
|
|
98
|
-
if (descriptor.method !== undefined && typeof descriptor.method !== 'string') return;
|
|
99
|
-
if (descriptor.kind === 'data' && ['list', 'infiniteList', 'one', 'many', 'select', 'selectDefaults'].includes(descriptor.action)) return descriptor;
|
|
100
|
-
if (descriptor.kind === 'task' && ['list', 'one'].includes(descriptor.action)) return descriptor;
|
|
101
|
-
if (descriptor.kind === 'access' && (descriptor.action === undefined || descriptor.action === 'can')) return descriptor;
|
|
102
|
-
if (descriptor.kind === 'custom' && (descriptor.action === undefined || typeof descriptor.action === 'string')) return descriptor;
|
|
209
|
+
export function parseQueryKey(value: unknown): QueryDescriptor|undefined {
|
|
210
|
+
return snapshotKey(value)?.[0];
|
|
103
211
|
}
|
|
104
212
|
|
|
105
|
-
|
|
213
|
+
/** Inspect validity without granting a type to the original mutable or proxy-backed value. */
|
|
214
|
+
export function isQueryKey(value: unknown): boolean { return parseQueryKey(value)!==undefined; }
|
|
215
|
+
|
|
216
|
+
function matchField(descriptor: QueryDescriptor,matcher: QueryMatcher,key: keyof QueryMatcher): boolean {
|
|
217
|
+
return !Object.hasOwn(matcher,key)||descriptor[key]===matcher[key];
|
|
218
|
+
}
|
|
106
219
|
|
|
107
|
-
function
|
|
108
|
-
|
|
109
|
-
if
|
|
110
|
-
if
|
|
111
|
-
|
|
220
|
+
function matches(queryKey: readonly unknown[],matcher: QueryMatcher): boolean {
|
|
221
|
+
const parsed=parseQueryKey(queryKey);
|
|
222
|
+
if(!parsed||parsed.contract!==matcher.contract) return false;
|
|
223
|
+
if(matcher.kind!==undefined&&matcher.kind!==parsed.kind) return false;
|
|
224
|
+
if(Object.hasOwn(matcher,'provider')&&parsed.provider!==(matcher.provider??'default')) return false;
|
|
225
|
+
return matchField(parsed,matcher,'tenant')&&matchField(parsed,matcher,'resource')
|
|
226
|
+
&&matchField(parsed,matcher,'action')&&matchField(parsed,matcher,'id')&&matchField(parsed,matcher,'method');
|
|
112
227
|
}
|
|
113
228
|
|
|
114
|
-
export function queryKeyMatches(queryKey: readonly unknown[],
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
if (matcher.kind !== undefined && matcher.kind !== parsed.kind) return false;
|
|
118
|
-
if (has(matcher, 'provider')) {
|
|
119
|
-
if (matcher.provider === undefined) return parsed.provider === 'default';
|
|
120
|
-
if (matcher.provider !== parsed.provider) return false;
|
|
121
|
-
}
|
|
122
|
-
return matchField(parsed, matcher, 'tenant') && matchField(parsed, matcher, 'resource') && matchField(parsed, matcher, 'action') && matchField(parsed, matcher, 'id') && matchField(parsed, matcher, 'method');
|
|
229
|
+
export function queryKeyMatches(queryKey: readonly unknown[],matcher: QueryMatcher={}): boolean {
|
|
230
|
+
const captured=snapshotMatcher(matcher);
|
|
231
|
+
return captured!==undefined&&matches(queryKey,captured);
|
|
123
232
|
}
|
|
124
233
|
|
|
125
|
-
export function dataQueryMatches(queryKey: readonly unknown[],
|
|
126
|
-
|
|
234
|
+
export function dataQueryMatches(queryKey: readonly unknown[],matcher: DataQueryMatcher): boolean {
|
|
235
|
+
const captured=snapshotMatcher(matcher);
|
|
236
|
+
return captured!==undefined&&!Object.hasOwn(captured,'kind')&&typeof captured.resource==='string'&&
|
|
237
|
+
matches(queryKey,{ ...captured,kind: 'data' });
|
|
127
238
|
}
|