@jskit-ai/crud-core 0.1.25 → 0.1.27

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.
@@ -1,12 +1,14 @@
1
1
  export default Object.freeze({
2
2
  packageVersion: 1,
3
3
  packageId: "@jskit-ai/crud-core",
4
- version: "0.1.25",
4
+ version: "0.1.27",
5
+ kind: "runtime",
5
6
  description: "Shared CRUD helpers used by CRUD modules.",
6
7
  dependsOn: [
7
8
  "@jskit-ai/kernel",
8
9
  "@jskit-ai/realtime",
9
10
  "@jskit-ai/shell-web",
11
+ "@jskit-ai/users-core",
10
12
  "@jskit-ai/users-web"
11
13
  ],
12
14
  capabilities: {
@@ -24,7 +26,7 @@ export default Object.freeze({
24
26
  mutations: {
25
27
  dependencies: {
26
28
  runtime: {
27
- "@jskit-ai/crud-core": "0.1.25"
29
+ "@jskit-ai/crud-core": "0.1.27"
28
30
  },
29
31
  dev: {}
30
32
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/crud-core",
3
- "version": "0.1.25",
3
+ "version": "0.1.27",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -10,13 +10,15 @@
10
10
  "./client/composables/createCrudClientSupport": "./src/client/composables/createCrudClientSupport.js",
11
11
  "./client/composables/crudClientSupportHelpers": "./src/client/composables/crudClientSupportHelpers.js",
12
12
  "./client/composables/useCrudRealtimeInvalidation": "./src/client/composables/useCrudRealtimeInvalidation.js",
13
- "./server/repositorySupport": "./src/server/repositorySupport.js"
13
+ "./server/repositorySupport": "./src/server/repositorySupport.js",
14
+ "./server/crudModuleConfig": "./src/server/crudModuleConfig.js"
14
15
  },
15
16
  "dependencies": {
16
17
  "@tanstack/vue-query": "^5.90.5",
17
- "@jskit-ai/kernel": "0.1.17",
18
- "@jskit-ai/realtime": "0.1.16",
19
- "@jskit-ai/shell-web": "0.1.16",
20
- "@jskit-ai/users-web": "0.1.27"
18
+ "@jskit-ai/kernel": "0.1.19",
19
+ "@jskit-ai/realtime": "0.1.18",
20
+ "@jskit-ai/shell-web": "0.1.18",
21
+ "@jskit-ai/users-core": "0.1.28",
22
+ "@jskit-ai/users-web": "0.1.33"
21
23
  }
22
24
  }
@@ -8,44 +8,123 @@ import {
8
8
  invalidateCrudQueries,
9
9
  crudListQueryKey,
10
10
  crudViewQueryKey,
11
- toRouteRecordId
11
+ toRouteRecordId,
12
+ normalizeCrudRouteParamName,
13
+ resolveCrudRecordPathTemplates,
14
+ resolveCrudRecordPathParams
12
15
  } from "./crudClientSupportHelpers.js";
13
16
  import { useCrudRealtimeInvalidation } from "./useCrudRealtimeInvalidation.js";
14
17
 
18
+ function normalizeText(value = "") {
19
+ return String(value || "").trim();
20
+ }
21
+
22
+ function normalizeRouteParams(params = {}) {
23
+ const source = params && typeof params === "object" && !Array.isArray(params) ? params : {};
24
+ const normalized = {};
25
+
26
+ for (const [rawKey, rawValue] of Object.entries(source)) {
27
+ const key = normalizeText(rawKey);
28
+ if (!key) {
29
+ continue;
30
+ }
31
+
32
+ const value = Array.isArray(rawValue) ? rawValue[0] : rawValue;
33
+ const normalizedValue = normalizeText(value);
34
+ if (!normalizedValue) {
35
+ continue;
36
+ }
37
+
38
+ normalized[key] = normalizedValue;
39
+ }
40
+
41
+ return normalized;
42
+ }
43
+
44
+ function normalizePathTemplate(value = "") {
45
+ const normalized = normalizeText(value)
46
+ .replace(/\\/g, "/")
47
+ .replace(/\/{2,}/g, "/")
48
+ .replace(/^\/+|\/+$/g, "");
49
+
50
+ return normalized ? `/${normalized}` : "";
51
+ }
52
+
53
+ function resolvePathTemplate(pathTemplate = "", { routeParams = {}, params = {}, context = "resolvePathTemplate" } = {}) {
54
+ const normalizedTemplate = normalizePathTemplate(pathTemplate);
55
+ if (!normalizedTemplate) {
56
+ return "";
57
+ }
58
+
59
+ const resolvedParams = {
60
+ ...normalizeRouteParams(routeParams),
61
+ ...normalizeRouteParams(params)
62
+ };
63
+ const missingParams = [];
64
+ const resolvedPath = normalizedTemplate.replace(/:([A-Za-z][A-Za-z0-9_]*)/g, (_, key) => {
65
+ const value = normalizeText(resolvedParams[key]);
66
+ if (!value) {
67
+ missingParams.push(key);
68
+ return `:${key}`;
69
+ }
70
+
71
+ return encodeURIComponent(value);
72
+ });
73
+
74
+ if (missingParams.length > 0) {
75
+ throw new Error(`${context} missing route parameter(s): ${missingParams.join(", ")}.`);
76
+ }
77
+
78
+ return resolvedPath;
79
+ }
80
+
15
81
  function useCrudClientContext(source = {}) {
16
82
  const crudConfig = resolveCrudClientConfig(source);
17
83
  const paths = usePaths();
18
84
  const route = useRoute();
19
85
  const workspaceSlugToken = computed(() => paths.workspaceSlug.value);
20
- const listPath = computed(() => paths.page(crudConfig.relativePath));
21
- const createPath = computed(() => paths.page(`${crudConfig.relativePath}/new`));
86
+ const defaultRecordIdParam = "recordId";
87
+ const listPathTemplate = crudConfig.relativePath;
88
+ const createPathTemplate = `${crudConfig.relativePath}/new`;
89
+ const defaultRecordPathTemplates = resolveCrudRecordPathTemplates(listPathTemplate, defaultRecordIdParam);
22
90
 
23
- function listQueryKey(surfaceId = "") {
24
- const normalizedSurfaceId = String(surfaceId || paths.currentSurfaceId.value || "").trim();
25
- return crudListQueryKey(normalizedSurfaceId, workspaceSlugToken.value, crudConfig.namespace);
91
+ function resolvePath(pathTemplate = "", params = {}) {
92
+ const resolvedPath = resolvePathTemplate(pathTemplate, {
93
+ routeParams: route.params,
94
+ params,
95
+ context: "useCrudClientContext.resolvePath"
96
+ });
97
+ return resolvedPath ? paths.page(resolvedPath) : "";
26
98
  }
27
99
 
28
- function viewQueryKey(surfaceId = "", recordId = 0) {
29
- const normalizedSurfaceId = String(surfaceId || paths.currentSurfaceId.value || "").trim();
30
- return crudViewQueryKey(normalizedSurfaceId, workspaceSlugToken.value, recordId, crudConfig.namespace);
100
+ function resolveApiPath(pathTemplate = "", params = {}) {
101
+ const resolvedPath = resolvePathTemplate(pathTemplate, {
102
+ routeParams: route.params,
103
+ params,
104
+ context: "useCrudClientContext.resolveApiPath"
105
+ });
106
+ return resolvedPath ? paths.api(resolvedPath) : "";
31
107
  }
32
108
 
33
- function resolveViewPath(recordIdLike) {
34
- const recordId = toRouteRecordId(recordIdLike);
35
- if (!recordId) {
36
- return "";
37
- }
109
+ const listPath = computed(() => resolvePath(listPathTemplate));
110
+ const createPath = computed(() => resolvePath(createPathTemplate));
38
111
 
39
- return paths.page(`${crudConfig.relativePath}/${recordId}`);
112
+ function resolveRecordPathTemplates(recordIdParam = defaultRecordIdParam) {
113
+ return resolveCrudRecordPathTemplates(listPathTemplate, recordIdParam);
40
114
  }
41
115
 
42
- function resolveEditPath(recordIdLike) {
43
- const recordId = toRouteRecordId(recordIdLike);
44
- if (!recordId) {
45
- return "";
46
- }
116
+ function resolveRecordParams(recordIdLike = 0, { recordIdParam = defaultRecordIdParam } = {}) {
117
+ return resolveCrudRecordPathParams(recordIdLike, recordIdParam);
118
+ }
119
+
120
+ function listQueryKey(surfaceId = "") {
121
+ const normalizedSurfaceId = String(surfaceId || paths.currentSurfaceId.value || "").trim();
122
+ return crudListQueryKey(normalizedSurfaceId, workspaceSlugToken.value, crudConfig.namespace);
123
+ }
47
124
 
48
- return paths.page(`${crudConfig.relativePath}/${recordId}/edit`);
125
+ function viewQueryKey(surfaceId = "", recordId = 0) {
126
+ const normalizedSurfaceId = String(surfaceId || paths.currentSurfaceId.value || "").trim();
127
+ return crudViewQueryKey(normalizedSurfaceId, workspaceSlugToken.value, recordId, crudConfig.namespace);
49
128
  }
50
129
 
51
130
  function scopeQueryKey() {
@@ -59,36 +138,47 @@ function useCrudClientContext(source = {}) {
59
138
  return Object.freeze({
60
139
  route,
61
140
  crudConfig,
141
+ listPathTemplate,
142
+ createPathTemplate,
143
+ defaultRecordIdParam,
144
+ viewPathTemplate: defaultRecordPathTemplates.viewPathTemplate,
145
+ editPathTemplate: defaultRecordPathTemplates.editPathTemplate,
146
+ resolveRecordPathTemplates,
147
+ resolveRecordParams,
148
+ resolvePath,
149
+ resolveApiPath,
62
150
  listPath,
63
151
  createPath,
64
152
  listQueryKey,
65
153
  viewQueryKey,
66
154
  scopeQueryKey,
67
155
  invalidateQueries,
68
- formatDateTime,
69
- resolveViewPath,
70
- resolveEditPath
156
+ formatDateTime
71
157
  });
72
158
  }
73
159
 
74
- function normalizeRecordIdParam(value) {
75
- const normalized = String(value || "").trim();
76
- if (!normalized) {
77
- throw new TypeError("useCrudRecordRuntime requires a non-empty recordIdParam.");
78
- }
79
-
80
- return normalized;
81
- }
82
-
83
160
  function useCrudRecordRuntime(source = {}, { recordIdParam = "recordId" } = {}) {
84
- const normalizedRecordIdParam = normalizeRecordIdParam(recordIdParam);
161
+ const normalizedRecordIdParam = normalizeCrudRouteParamName(recordIdParam, {
162
+ context: "useCrudRecordRuntime"
163
+ });
85
164
  const crudContext = useCrudClientContext(source);
86
165
  useCrudRealtimeInvalidation(crudContext.crudConfig.namespace);
87
166
  const router = useRouter();
167
+ const recordPathTemplates = crudContext.resolveRecordPathTemplates(normalizedRecordIdParam);
88
168
  const recordId = computed(() => toRouteRecordId(crudContext.route.params[normalizedRecordIdParam]));
89
- const apiSuffix = computed(() => `${crudContext.crudConfig.relativePath}/${recordId.value}`);
90
- const viewPath = computed(() => crudContext.resolveViewPath(recordId.value));
91
- const editPath = computed(() => crudContext.resolveEditPath(recordId.value));
169
+ const apiSuffix = computed(() => `${crudContext.crudConfig.apiRelativePath}/${recordId.value}`);
170
+ const viewPath = computed(() =>
171
+ crudContext.resolvePath(
172
+ recordPathTemplates.viewPathTemplate,
173
+ crudContext.resolveRecordParams(recordId.value, { recordIdParam: normalizedRecordIdParam })
174
+ )
175
+ );
176
+ const editPath = computed(() =>
177
+ crudContext.resolvePath(
178
+ recordPathTemplates.editPathTemplate,
179
+ crudContext.resolveRecordParams(recordId.value, { recordIdParam: normalizedRecordIdParam })
180
+ )
181
+ );
92
182
  const listPath = crudContext.listPath;
93
183
 
94
184
  function viewQueryKey(surfaceId = "") {
@@ -105,8 +195,11 @@ function useCrudRecordRuntime(source = {}, { recordIdParam = "recordId" } = {})
105
195
  async function invalidateAndGoView(queryClient, recordIdLike = recordId.value) {
106
196
  await crudContext.invalidateQueries(queryClient);
107
197
 
108
- const targetRecordId = toRouteRecordId(recordIdLike);
109
- const targetPath = crudContext.resolveViewPath(targetRecordId || recordId.value);
198
+ const targetRecordId = toRouteRecordId(recordIdLike) || recordId.value;
199
+ const targetPath = crudContext.resolvePath(
200
+ recordPathTemplates.viewPathTemplate,
201
+ crudContext.resolveRecordParams(targetRecordId, { recordIdParam: normalizedRecordIdParam })
202
+ );
110
203
  if (targetPath) {
111
204
  await router.push(targetPath);
112
205
  }
@@ -129,8 +222,9 @@ function useCrudCreateRuntime(source = {}) {
129
222
  const crudContext = useCrudClientContext(source);
130
223
  useCrudRealtimeInvalidation(crudContext.crudConfig.namespace);
131
224
  const router = useRouter();
225
+ const recordPathTemplates = crudContext.resolveRecordPathTemplates();
132
226
  const listPath = crudContext.listPath;
133
- const apiSuffix = crudContext.crudConfig.relativePath;
227
+ const apiSuffix = crudContext.crudConfig.apiRelativePath;
134
228
 
135
229
  function createQueryKey(surfaceId = "") {
136
230
  return [...crudContext.listQueryKey(surfaceId), "create"];
@@ -139,7 +233,10 @@ function useCrudCreateRuntime(source = {}) {
139
233
  async function invalidateAndGoView(queryClient, recordIdLike) {
140
234
  await crudContext.invalidateQueries(queryClient);
141
235
 
142
- const targetPath = crudContext.resolveViewPath(recordIdLike);
236
+ const targetPath = crudContext.resolvePath(
237
+ recordPathTemplates.viewPathTemplate,
238
+ crudContext.resolveRecordParams(recordIdLike)
239
+ );
143
240
  if (targetPath) {
144
241
  await router.push(targetPath);
145
242
  }
@@ -158,7 +255,7 @@ function useCrudListRuntime(source = {}) {
158
255
  const crudContext = useCrudClientContext(source);
159
256
  useCrudRealtimeInvalidation(crudContext.crudConfig.namespace);
160
257
  const createPath = crudContext.createPath;
161
- const apiSuffix = crudContext.crudConfig.relativePath;
258
+ const apiSuffix = crudContext.crudConfig.apiRelativePath;
162
259
 
163
260
  function listQueryKey(surfaceId = "") {
164
261
  return crudContext.listQueryKey(surfaceId);
@@ -3,6 +3,7 @@ import { normalizeRouteVisibilityToken } from "@jskit-ai/kernel/shared/support/v
3
3
  import { formatDateTime } from "@jskit-ai/kernel/shared/support";
4
4
 
5
5
  const DEFAULT_CRUD_OWNERSHIP_FILTER = "workspace";
6
+ const ROUTE_PARAM_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9_]*$/;
6
7
 
7
8
  function requireCrudNamespace(namespace, { context = "resolveCrudClientConfig" } = {}) {
8
9
  const normalizedNamespace = normalizeLowerText(namespace);
@@ -40,11 +41,16 @@ function resolveCrudClientConfig(source = {}) {
40
41
  Object.hasOwn(payload, "relativePath") ? payload.relativePath : inferredRelativePath,
41
42
  { context: "resolveCrudClientConfig" }
42
43
  );
44
+ const apiRelativePath = normalizeRelativePath(
45
+ Object.hasOwn(payload, "apiRelativePath") ? payload.apiRelativePath : relativePath,
46
+ { context: "resolveCrudClientConfig" }
47
+ );
43
48
 
44
49
  return Object.freeze({
45
50
  namespace,
46
51
  ownershipFilter,
47
- relativePath
52
+ relativePath,
53
+ apiRelativePath
48
54
  });
49
55
  }
50
56
 
@@ -97,6 +103,49 @@ function toRouteRecordId(value) {
97
103
  return Number.isInteger(parsed) && parsed > 0 ? parsed : 0;
98
104
  }
99
105
 
106
+ function normalizeCrudRouteParamName(value, { context = "normalizeCrudRouteParamName" } = {}) {
107
+ const normalizedValue = normalizeText(value);
108
+ if (!normalizedValue) {
109
+ throw new TypeError(`${context} requires a non-empty route parameter name.`);
110
+ }
111
+ if (!ROUTE_PARAM_NAME_PATTERN.test(normalizedValue)) {
112
+ throw new TypeError(
113
+ `${context} route parameter "${normalizedValue}" is invalid. Use letters, numbers, and underscores only.`
114
+ );
115
+ }
116
+
117
+ return normalizedValue;
118
+ }
119
+
120
+ function resolveCrudRecordPathTemplates(relativePath = "", recordIdParam = "recordId") {
121
+ const normalizedRelativePath = normalizeRelativePath(relativePath, {
122
+ context: "resolveCrudRecordPathTemplates"
123
+ });
124
+ const normalizedRecordIdParam = normalizeCrudRouteParamName(recordIdParam, {
125
+ context: "resolveCrudRecordPathTemplates"
126
+ });
127
+ const recordSegment = `:${normalizedRecordIdParam}`;
128
+
129
+ return Object.freeze({
130
+ viewPathTemplate: `${normalizedRelativePath}/${recordSegment}`,
131
+ editPathTemplate: `${normalizedRelativePath}/${recordSegment}/edit`
132
+ });
133
+ }
134
+
135
+ function resolveCrudRecordPathParams(recordIdLike = 0, recordIdParam = "recordId") {
136
+ const normalizedRecordIdParam = normalizeCrudRouteParamName(recordIdParam, {
137
+ context: "resolveCrudRecordPathParams"
138
+ });
139
+ const normalizedRecordId = toRouteRecordId(recordIdLike);
140
+ if (!normalizedRecordId) {
141
+ return Object.freeze({});
142
+ }
143
+
144
+ return Object.freeze({
145
+ [normalizedRecordIdParam]: String(normalizedRecordId)
146
+ });
147
+ }
148
+
100
149
  export {
101
150
  DEFAULT_CRUD_OWNERSHIP_FILTER,
102
151
  requireCrudNamespace,
@@ -107,5 +156,8 @@ export {
107
156
  invalidateCrudQueries,
108
157
  crudListQueryKey,
109
158
  crudViewQueryKey,
110
- toRouteRecordId
159
+ toRouteRecordId,
160
+ normalizeCrudRouteParamName,
161
+ resolveCrudRecordPathTemplates,
162
+ resolveCrudRecordPathParams
111
163
  };
@@ -0,0 +1,310 @@
1
+ import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
2
+ import { normalizeSurfaceId } from "@jskit-ai/kernel/shared/surface/registry";
3
+ import {
4
+ resolveApiBasePath
5
+ } from "@jskit-ai/users-core/shared/support/usersApiPaths";
6
+ import {
7
+ USERS_ROUTE_VISIBILITY_LEVELS,
8
+ normalizeScopedRouteVisibility,
9
+ isWorkspaceVisibility
10
+ } from "@jskit-ai/users-core/shared/support/usersVisibility";
11
+
12
+ const DEFAULT_OWNERSHIP_FILTER = "workspace";
13
+ const CRUD_REQUESTED_OWNERSHIP_FILTER_AUTO = "auto";
14
+ const CRUD_REQUESTED_OWNERSHIP_FILTER_SET = new Set([
15
+ ...USERS_ROUTE_VISIBILITY_LEVELS,
16
+ CRUD_REQUESTED_OWNERSHIP_FILTER_AUTO
17
+ ]);
18
+ const CRUD_MODULE_ID = "crud";
19
+
20
+ function asRecord(value) {
21
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
22
+ return {};
23
+ }
24
+
25
+ return value;
26
+ }
27
+
28
+ function normalizeCrudNamespace(value) {
29
+ return normalizeText(value)
30
+ .toLowerCase()
31
+ .replace(/[^a-z0-9-]+/g, "-")
32
+ .replace(/-+/g, "-")
33
+ .replace(/^-+|-+$/g, "");
34
+ }
35
+
36
+ function normalizeCrudOwnershipFilter(value, { fallback = DEFAULT_OWNERSHIP_FILTER } = {}) {
37
+ return normalizeScopedRouteVisibility(value, { fallback });
38
+ }
39
+
40
+ function normalizeCrudRequestedOwnershipFilter(value, { fallback = CRUD_REQUESTED_OWNERSHIP_FILTER_AUTO } = {}) {
41
+ const normalized = normalizeText(value).toLowerCase();
42
+ if (CRUD_REQUESTED_OWNERSHIP_FILTER_SET.has(normalized)) {
43
+ return normalized;
44
+ }
45
+
46
+ const normalizedFallback = normalizeText(fallback).toLowerCase();
47
+ if (CRUD_REQUESTED_OWNERSHIP_FILTER_SET.has(normalizedFallback)) {
48
+ return normalizedFallback;
49
+ }
50
+
51
+ return CRUD_REQUESTED_OWNERSHIP_FILTER_AUTO;
52
+ }
53
+
54
+ function requireCrudNamespace(namespace, { context = "CRUD config" } = {}) {
55
+ const normalizedNamespace = normalizeCrudNamespace(namespace);
56
+ if (!normalizedNamespace) {
57
+ throw new TypeError(`${context} requires a non-empty namespace.`);
58
+ }
59
+
60
+ return normalizedNamespace;
61
+ }
62
+
63
+ function resolveCrudNamespacePath(namespace = "") {
64
+ const normalizedNamespace = requireCrudNamespace(namespace, {
65
+ context: "resolveCrudNamespacePath"
66
+ });
67
+ return `/${normalizedNamespace}`;
68
+ }
69
+
70
+ function resolveCrudRelativePath(namespace = "") {
71
+ return resolveCrudNamespacePath(namespace);
72
+ }
73
+
74
+ function normalizeCrudRelativePath(relativePath = "", { context = "resolveCrudSurfacePolicy" } = {}) {
75
+ const normalizedPath = normalizeText(relativePath);
76
+ if (!normalizedPath) {
77
+ throw new TypeError(`${context} requires a non-empty relativePath.`);
78
+ }
79
+
80
+ const withLeadingSlash = normalizedPath.startsWith("/") ? normalizedPath : `/${normalizedPath}`;
81
+ const compacted = withLeadingSlash.replace(/\/{2,}/g, "/");
82
+ return compacted === "/" ? "/" : compacted.replace(/\/+$/, "") || "/";
83
+ }
84
+
85
+ function resolveCrudApiBasePath({ namespace = "", surfaceRequiresWorkspace = false } = {}) {
86
+ const relativePath = resolveCrudRelativePath(namespace);
87
+ return resolveApiBasePath({
88
+ surfaceRequiresWorkspace: surfaceRequiresWorkspace === true,
89
+ relativePath
90
+ });
91
+ }
92
+
93
+ function resolveCrudTableName(namespace = "") {
94
+ const normalizedNamespace = requireCrudNamespace(namespace, {
95
+ context: "resolveCrudTableName"
96
+ });
97
+ return `crud_${normalizedNamespace.replace(/-/g, "_")}`;
98
+ }
99
+
100
+ function resolveCrudTokenPart(namespace = "") {
101
+ const normalizedNamespace = requireCrudNamespace(namespace, {
102
+ context: "resolveCrudTokenPart"
103
+ });
104
+ return normalizedNamespace.replace(/-/g, "_");
105
+ }
106
+
107
+ function resolveCrudActionIdPrefix(namespace = "") {
108
+ const tokenPart = resolveCrudTokenPart(namespace);
109
+ return `crud.${tokenPart}`;
110
+ }
111
+
112
+ function resolveCrudContributorId(namespace = "") {
113
+ const tokenPart = resolveCrudTokenPart(namespace);
114
+ return `crud.${tokenPart}`;
115
+ }
116
+
117
+ function resolveCrudDomain(namespace = "") {
118
+ return "crud";
119
+ }
120
+
121
+ function resolveCrudToken(namespace = "", suffix = "") {
122
+ const contributorId = resolveCrudContributorId(namespace);
123
+ return suffix ? `${contributorId}.${suffix}` : contributorId;
124
+ }
125
+
126
+ function resolveCrudConfig(source = {}) {
127
+ const settings = source && typeof source === "object" && !Array.isArray(source) ? source : {};
128
+ const namespace = requireCrudNamespace(settings.namespace, {
129
+ context: "resolveCrudConfig"
130
+ });
131
+ const ownershipFilter = normalizeCrudOwnershipFilter(settings.ownershipFilter);
132
+
133
+ return Object.freeze({
134
+ namespace,
135
+ ownershipFilter,
136
+ workspaceScoped: isWorkspaceVisibility(ownershipFilter),
137
+ namespacePath: resolveCrudNamespacePath(namespace),
138
+ relativePath: resolveCrudRelativePath(namespace),
139
+ apiBasePath: resolveCrudApiBasePath({ namespace }),
140
+ tableName: resolveCrudTableName(namespace),
141
+ actionIdPrefix: resolveCrudActionIdPrefix(namespace),
142
+ contributorId: resolveCrudContributorId(namespace),
143
+ domain: resolveCrudDomain(namespace),
144
+ repositoryToken: resolveCrudToken(namespace, "repository"),
145
+ serviceToken: resolveCrudToken(namespace, "service")
146
+ });
147
+ }
148
+
149
+ function normalizeSurfaceDefinitions(sourceDefinitions = {}) {
150
+ const definitions = asRecord(sourceDefinitions);
151
+ const normalized = {};
152
+
153
+ for (const [key, value] of Object.entries(definitions)) {
154
+ const definition = asRecord(value);
155
+ const surfaceId = normalizeSurfaceId(definition.id || key);
156
+ if (!surfaceId) {
157
+ continue;
158
+ }
159
+
160
+ normalized[surfaceId] = Object.freeze({
161
+ ...definition,
162
+ id: surfaceId,
163
+ enabled: definition.enabled !== false,
164
+ requiresAuth: definition.requiresAuth === true,
165
+ requiresWorkspace: definition.requiresWorkspace === true
166
+ });
167
+ }
168
+
169
+ return Object.freeze(normalized);
170
+ }
171
+
172
+ function resolveOwnershipFilterFromSurfaceDefinition(definition = {}) {
173
+ if (definition.requiresWorkspace === true) {
174
+ return "workspace";
175
+ }
176
+ if (definition.requiresAuth === true) {
177
+ return "user";
178
+ }
179
+ return "public";
180
+ }
181
+
182
+ function resolveCrudSurfacePolicy(
183
+ sourceConfig = {},
184
+ {
185
+ surfaceDefinitions = {},
186
+ defaultSurfaceId = "",
187
+ context = "resolveCrudSurfacePolicy"
188
+ } = {}
189
+ ) {
190
+ const config = asRecord(sourceConfig);
191
+ const normalizedDefinitions = normalizeSurfaceDefinitions(surfaceDefinitions);
192
+ const requestedSurfaceId = normalizeSurfaceId(config.surface);
193
+ const fallbackSurfaceId = normalizeSurfaceId(defaultSurfaceId);
194
+ const surfaceId = requestedSurfaceId || fallbackSurfaceId;
195
+ if (!surfaceId) {
196
+ throw new Error(`${context} requires surface or defaultSurfaceId.`);
197
+ }
198
+
199
+ const surfaceDefinition = normalizedDefinitions[surfaceId];
200
+ if (!surfaceDefinition) {
201
+ throw new Error(`${context} cannot resolve surface "${surfaceId}".`);
202
+ }
203
+ if (surfaceDefinition.enabled === false) {
204
+ throw new Error(`${context} surface "${surfaceId}" is disabled.`);
205
+ }
206
+
207
+ const requestedOwnershipFilter = normalizeCrudRequestedOwnershipFilter(config.ownershipFilter);
208
+ const ownershipFilter =
209
+ requestedOwnershipFilter === CRUD_REQUESTED_OWNERSHIP_FILTER_AUTO
210
+ ? resolveOwnershipFilterFromSurfaceDefinition(surfaceDefinition)
211
+ : normalizeScopedRouteVisibility(requestedOwnershipFilter, {
212
+ fallback: "public"
213
+ });
214
+
215
+ if (isWorkspaceVisibility(ownershipFilter) && surfaceDefinition.requiresWorkspace !== true) {
216
+ throw new Error(
217
+ `${context} ownershipFilter "${ownershipFilter}" requires a workspace-enabled surface.`
218
+ );
219
+ }
220
+
221
+ const relativePath = normalizeCrudRelativePath(config.relativePath || resolveCrudRelativePath(config.namespace), {
222
+ context
223
+ });
224
+
225
+ return Object.freeze({
226
+ surfaceId,
227
+ ownershipFilter,
228
+ requestedOwnershipFilter,
229
+ workspaceScoped: isWorkspaceVisibility(ownershipFilter),
230
+ relativePath,
231
+ surfaceDefinition
232
+ });
233
+ }
234
+
235
+ function resolveCrudSurfacePolicyFromAppConfig(sourceConfig = {}, appConfig = {}, options = {}) {
236
+ const config = asRecord(appConfig);
237
+ return resolveCrudSurfacePolicy(sourceConfig, {
238
+ ...asRecord(options),
239
+ surfaceDefinitions: config.surfaceDefinitions,
240
+ defaultSurfaceId: config.surfaceDefaultId
241
+ });
242
+ }
243
+
244
+ function resolveCrudConfigsFromModules(modulesSource = {}) {
245
+ const modules = modulesSource && typeof modulesSource === "object" && !Array.isArray(modulesSource)
246
+ ? modulesSource
247
+ : {};
248
+ const configs = [];
249
+ const seenContributorIds = new Set();
250
+
251
+ for (const moduleConfig of Object.values(modules)) {
252
+ const source = moduleConfig && typeof moduleConfig === "object" && !Array.isArray(moduleConfig)
253
+ ? moduleConfig
254
+ : {};
255
+
256
+ if (normalizeText(source.module).toLowerCase() !== CRUD_MODULE_ID) {
257
+ continue;
258
+ }
259
+
260
+ const resolved = resolveCrudConfig(source);
261
+ if (seenContributorIds.has(resolved.contributorId)) {
262
+ throw new Error(`Duplicate CRUD namespace in config.modules: "${resolved.namespace}".`);
263
+ }
264
+ seenContributorIds.add(resolved.contributorId);
265
+ configs.push(resolved);
266
+ }
267
+
268
+ return configs;
269
+ }
270
+
271
+ function resolveCrudConfigFromModules(modulesSource = {}, options = {}) {
272
+ const configs = resolveCrudConfigsFromModules(modulesSource);
273
+ const hasNamespace = Object.hasOwn(options, "namespace");
274
+ if (hasNamespace) {
275
+ const normalizedNamespace = requireCrudNamespace(options.namespace, {
276
+ context: "resolveCrudConfigFromModules"
277
+ });
278
+ return configs.find((entry) => entry.namespace === normalizedNamespace) || null;
279
+ }
280
+
281
+ if (configs.length === 1) {
282
+ return configs[0];
283
+ }
284
+
285
+ return null;
286
+ }
287
+
288
+ export {
289
+ CRUD_MODULE_ID,
290
+ DEFAULT_OWNERSHIP_FILTER,
291
+ CRUD_REQUESTED_OWNERSHIP_FILTER_AUTO,
292
+ normalizeCrudNamespace,
293
+ normalizeCrudOwnershipFilter,
294
+ normalizeCrudRequestedOwnershipFilter,
295
+ isWorkspaceVisibility,
296
+ requireCrudNamespace,
297
+ resolveCrudNamespacePath,
298
+ resolveCrudRelativePath,
299
+ normalizeCrudRelativePath,
300
+ resolveCrudApiBasePath,
301
+ resolveCrudTableName,
302
+ resolveCrudActionIdPrefix,
303
+ resolveCrudContributorId,
304
+ resolveCrudDomain,
305
+ resolveCrudConfig,
306
+ resolveCrudSurfacePolicy,
307
+ resolveCrudSurfacePolicyFromAppConfig,
308
+ resolveCrudConfigsFromModules,
309
+ resolveCrudConfigFromModules
310
+ };
@@ -1,4 +1,6 @@
1
1
  import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
2
+ import { normalizeObjectInput } from "@jskit-ai/kernel/shared/validators/inputNormalization";
3
+ import { toSnakeCase } from "@jskit-ai/kernel/shared/support/stringCase";
2
4
 
3
5
  const DEFAULT_LIST_LIMIT = 20;
4
6
  const MAX_LIST_LIMIT = 100;
@@ -21,9 +23,103 @@ function requireCrudTableName(tableName, { context = "crudRepository" } = {}) {
21
23
  return normalizedTableName;
22
24
  }
23
25
 
26
+ function resolveColumnName(fieldKey, overrides = {}) {
27
+ const normalizedKey = String(fieldKey || "").trim();
28
+ if (!normalizedKey) {
29
+ return "";
30
+ }
31
+
32
+ const overrideValue = String(overrides?.[normalizedKey] || "").trim();
33
+ if (overrideValue) {
34
+ return overrideValue;
35
+ }
36
+
37
+ return toSnakeCase(normalizedKey);
38
+ }
39
+
40
+ function buildRepositoryColumnMetadata({
41
+ outputKeys = [],
42
+ writeKeys = [],
43
+ columnOverrides = {}
44
+ } = {}) {
45
+ const normalizedOutputKeys = (Array.isArray(outputKeys) ? outputKeys : [])
46
+ .map((key) => String(key || "").trim())
47
+ .filter(Boolean);
48
+ const normalizedWriteKeys = (Array.isArray(writeKeys) ? writeKeys : [])
49
+ .map((key) => String(key || "").trim())
50
+ .filter(Boolean);
51
+
52
+ const deriveMapping = (key) => {
53
+ const column = resolveColumnName(key, columnOverrides);
54
+ if (!column) {
55
+ return null;
56
+ }
57
+ return { key, column };
58
+ };
59
+
60
+ const outputMappings = normalizedOutputKeys.map(deriveMapping).filter(Boolean);
61
+ const writeMappings = normalizedWriteKeys.map(deriveMapping).filter(Boolean);
62
+ const selectColumns = Object.freeze(
63
+ [...new Set(outputMappings.map((mapping) => mapping.column))]
64
+ );
65
+
66
+ return Object.freeze({
67
+ selectColumns,
68
+ outputMappings: Object.freeze(outputMappings),
69
+ writeMappings: Object.freeze(writeMappings)
70
+ });
71
+ }
72
+
73
+ function mapRecordRow(row, fieldKeys = [], overrides = {}) {
74
+ if (!row) {
75
+ return null;
76
+ }
77
+
78
+ const mapped = {};
79
+ for (const key of fieldKeys) {
80
+ const normalizedKey = String(key || "").trim();
81
+ const columnName = resolveColumnName(normalizedKey, overrides);
82
+ if (!normalizedKey || !columnName) {
83
+ continue;
84
+ }
85
+ mapped[normalizedKey] = row[columnName];
86
+ }
87
+ return mapped;
88
+ }
89
+
90
+ function buildWritePayload(sourcePayload = {}, fieldKeys = [], overrides = {}) {
91
+ const source = normalizeObjectInput(sourcePayload);
92
+ const payload = {};
93
+ for (const key of fieldKeys) {
94
+ const normalizedKey = String(key || "").trim();
95
+ const columnName = resolveColumnName(normalizedKey, overrides);
96
+ if (!normalizedKey || !columnName) {
97
+ continue;
98
+ }
99
+ if (!Object.hasOwn(source, normalizedKey)) {
100
+ continue;
101
+ }
102
+ payload[columnName] = source[normalizedKey];
103
+ }
104
+ return payload;
105
+ }
106
+
107
+ function resolveCrudIdColumn(idColumn, { fallback = "id" } = {}) {
108
+ const normalized = String(idColumn ?? fallback ?? "").trim();
109
+ if (!normalized) {
110
+ throw new TypeError("crudRepository requires idColumn.");
111
+ }
112
+ return normalized;
113
+ }
114
+
24
115
  export {
25
116
  DEFAULT_LIST_LIMIT,
26
117
  MAX_LIST_LIMIT,
27
118
  normalizeCrudListLimit,
28
- requireCrudTableName
119
+ requireCrudTableName,
120
+ mapRecordRow,
121
+ buildWritePayload,
122
+ resolveColumnName,
123
+ resolveCrudIdColumn,
124
+ buildRepositoryColumnMetadata
29
125
  };
@@ -7,20 +7,25 @@ import {
7
7
  crudListQueryKey,
8
8
  crudViewQueryKey,
9
9
  toRouteRecordId,
10
- resolveCrudRecordChangedEvent
10
+ resolveCrudRecordChangedEvent,
11
+ normalizeCrudRouteParamName,
12
+ resolveCrudRecordPathTemplates,
13
+ resolveCrudRecordPathParams
11
14
  } from "../src/client/composables/crudClientSupportHelpers.js";
12
15
 
13
- test("resolveCrudClientConfig normalizes namespace, ownership filter, and derives relativePath", () => {
16
+ test("resolveCrudClientConfig normalizes namespace, ownership filter, and resolves route/api paths", () => {
14
17
  const config = resolveCrudClientConfig({
15
18
  namespace: " Customers ",
16
19
  ownershipFilter: "workspace",
17
- relativePath: "/crm/customers"
20
+ relativePath: "/ops/customers-ui",
21
+ apiRelativePath: "/crud/customers"
18
22
  });
19
23
 
20
24
  assert.deepEqual(config, {
21
25
  namespace: "customers",
22
26
  ownershipFilter: "workspace",
23
- relativePath: "/crm/customers"
27
+ relativePath: "/ops/customers-ui",
28
+ apiRelativePath: "/crud/customers"
24
29
  });
25
30
  });
26
31
 
@@ -31,6 +36,7 @@ test("resolveCrudClientConfig infers default relativePath from namespace", () =>
31
36
  });
32
37
 
33
38
  assert.equal(config.relativePath, "/appointments");
39
+ assert.equal(config.apiRelativePath, "/appointments");
34
40
  });
35
41
 
36
42
  test("resolveCrudClientConfig throws when namespace is missing", () => {
@@ -84,6 +90,35 @@ test("toRouteRecordId parses scalar and array params safely", () => {
84
90
  assert.equal(toRouteRecordId("not-a-number"), 0);
85
91
  });
86
92
 
93
+ test("normalizeCrudRouteParamName validates route parameter names", () => {
94
+ assert.equal(normalizeCrudRouteParamName("recordId"), "recordId");
95
+ assert.equal(normalizeCrudRouteParamName("addressId"), "addressId");
96
+ assert.throws(
97
+ () => normalizeCrudRouteParamName(""),
98
+ /requires a non-empty route parameter name/
99
+ );
100
+ assert.throws(
101
+ () => normalizeCrudRouteParamName("address-id"),
102
+ /route parameter "address-id" is invalid/
103
+ );
104
+ });
105
+
106
+ test("resolveCrudRecordPathTemplates supports custom route parameter names", () => {
107
+ assert.deepEqual(
108
+ resolveCrudRecordPathTemplates("/users/:userId/addresses", "addressId"),
109
+ {
110
+ viewPathTemplate: "/users/:userId/addresses/:addressId",
111
+ editPathTemplate: "/users/:userId/addresses/:addressId/edit"
112
+ }
113
+ );
114
+ });
115
+
116
+ test("resolveCrudRecordPathParams maps record ids to selected route parameter names", () => {
117
+ assert.deepEqual(resolveCrudRecordPathParams(42, "addressId"), { addressId: "42" });
118
+ assert.deepEqual(resolveCrudRecordPathParams("7", "recordId"), { recordId: "7" });
119
+ assert.deepEqual(resolveCrudRecordPathParams("invalid", "addressId"), {});
120
+ });
121
+
87
122
  test("resolveCrudRecordChangedEvent normalizes namespace into event channel", () => {
88
123
  assert.equal(resolveCrudRecordChangedEvent("Customers"), "customers.record.changed");
89
124
  assert.equal(resolveCrudRecordChangedEvent("customer-orders"), "customer_orders.record.changed");
@@ -3,7 +3,11 @@ import assert from "node:assert/strict";
3
3
  import {
4
4
  DEFAULT_LIST_LIMIT,
5
5
  normalizeCrudListLimit,
6
- requireCrudTableName
6
+ requireCrudTableName,
7
+ buildWritePayload,
8
+ mapRecordRow,
9
+ resolveCrudIdColumn,
10
+ buildRepositoryColumnMetadata
7
11
  } from "../src/server/repositorySupport.js";
8
12
 
9
13
  test("normalizeCrudListLimit enforces fallback and max", () => {
@@ -22,3 +26,48 @@ test("requireCrudTableName trims and rejects empty values", () => {
22
26
  /requires tableName/
23
27
  );
24
28
  });
29
+
30
+ test("mapRecordRow remaps rows by key/column pairs", () => {
31
+ const row = { some_column: 1, other_column: 2 };
32
+ const mapped = mapRecordRow(row, ["someKey", "otherKey"], {
33
+ someKey: "some_column",
34
+ otherKey: "other_column"
35
+ });
36
+ assert.deepEqual(mapped, {
37
+ someKey: 1,
38
+ otherKey: 2
39
+ });
40
+ });
41
+
42
+ test("buildWritePayload respects defined keys", () => {
43
+ const payload = buildWritePayload(
44
+ { foo: "bar", missing: true },
45
+ ["foo", "notPresent"],
46
+ {
47
+ foo: "foo_column",
48
+ notPresent: "not_present_column"
49
+ }
50
+ );
51
+ assert.deepEqual(payload, {
52
+ foo_column: "bar"
53
+ });
54
+ });
55
+
56
+ test("resolveCrudIdColumn falls back and rejects empties", () => {
57
+ assert.equal(resolveCrudIdColumn(" custom_id "), "custom_id");
58
+ assert.equal(resolveCrudIdColumn(undefined, { fallback: "fallback_id" }), "fallback_id");
59
+ assert.throws(() => resolveCrudIdColumn("", { fallback: "" }), /requires idColumn/);
60
+ });
61
+
62
+ test("buildRepositoryColumnMetadata normalizes columns and applies overrides", () => {
63
+ const metadata = buildRepositoryColumnMetadata({
64
+ outputKeys: ["firstName", "lastName"],
65
+ writeKeys: ["firstName"],
66
+ columnOverrides: { lastName: "surname" }
67
+ });
68
+
69
+ assert.deepEqual(metadata.selectColumns, Object.freeze(["first_name", "surname"]));
70
+ assert.equal(metadata.outputMappings.length, 2);
71
+ assert.equal(metadata.writeMappings.length, 1);
72
+ assert.equal(metadata.outputMappings[1].column, "surname");
73
+ });