@jskit-ai/crud-core 0.1.4

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.
@@ -0,0 +1,37 @@
1
+ export default Object.freeze({
2
+ packageVersion: 1,
3
+ packageId: "@jskit-ai/crud-core",
4
+ version: "0.1.4",
5
+ description: "Shared CRUD helpers used by CRUD modules.",
6
+ dependsOn: [
7
+ "@jskit-ai/kernel",
8
+ "@jskit-ai/realtime",
9
+ "@jskit-ai/shell-web",
10
+ "@jskit-ai/users-web"
11
+ ],
12
+ capabilities: {
13
+ provides: ["crud.core"],
14
+ requires: []
15
+ },
16
+ runtime: {
17
+ server: {
18
+ providers: []
19
+ },
20
+ client: {
21
+ providers: []
22
+ }
23
+ },
24
+ mutations: {
25
+ dependencies: {
26
+ runtime: {
27
+ "@jskit-ai/crud-core": "0.1.4"
28
+ },
29
+ dev: {}
30
+ },
31
+ packageJson: {
32
+ scripts: {}
33
+ },
34
+ procfile: {},
35
+ files: []
36
+ }
37
+ });
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@jskit-ai/crud-core",
3
+ "version": "0.1.4",
4
+ "type": "module",
5
+ "scripts": {
6
+ "test": "node --test"
7
+ },
8
+ "exports": {
9
+ "./client": "./src/client/index.js",
10
+ "./client/composables/*": "./src/client/composables/*.js",
11
+ "./server/repositorySupport": "./src/server/repositorySupport.js"
12
+ },
13
+ "dependencies": {
14
+ "@tanstack/vue-query": "^5.90.5",
15
+ "@jskit-ai/kernel": "0.1.4",
16
+ "@jskit-ai/realtime": "0.1.4",
17
+ "@jskit-ai/shell-web": "0.1.4",
18
+ "@jskit-ai/users-web": "0.1.4"
19
+ }
20
+ }
@@ -0,0 +1,201 @@
1
+ import { computed } from "vue";
2
+ import { useRoute, useRouter } from "vue-router";
3
+ import { usePaths } from "@jskit-ai/users-web/client/composables/usePaths";
4
+ import {
5
+ resolveCrudClientConfig,
6
+ formatDateTime,
7
+ crudScopeQueryKey,
8
+ invalidateCrudQueries,
9
+ crudListQueryKey,
10
+ crudViewQueryKey,
11
+ toRouteRecordId
12
+ } from "./crudClientSupportHelpers.js";
13
+ import { useCrudRealtimeInvalidation } from "./useCrudRealtimeInvalidation.js";
14
+
15
+ function useCrudClientContext(source = {}) {
16
+ const crudConfig = resolveCrudClientConfig(source);
17
+ const paths = usePaths();
18
+ const route = useRoute();
19
+ const workspaceSlugToken = computed(() => paths.workspaceSlug.value);
20
+ const listPath = computed(() => paths.page(crudConfig.relativePath));
21
+ const createPath = computed(() => paths.page(`${crudConfig.relativePath}/new`));
22
+
23
+ function listQueryKey(surfaceId = "") {
24
+ const normalizedSurfaceId = String(surfaceId || paths.currentSurfaceId.value || "").trim();
25
+ return crudListQueryKey(normalizedSurfaceId, workspaceSlugToken.value, crudConfig.namespace);
26
+ }
27
+
28
+ function viewQueryKey(surfaceId = "", recordId = 0) {
29
+ const normalizedSurfaceId = String(surfaceId || paths.currentSurfaceId.value || "").trim();
30
+ return crudViewQueryKey(normalizedSurfaceId, workspaceSlugToken.value, recordId, crudConfig.namespace);
31
+ }
32
+
33
+ function resolveViewPath(recordIdLike) {
34
+ const recordId = toRouteRecordId(recordIdLike);
35
+ if (!recordId) {
36
+ return "";
37
+ }
38
+
39
+ return paths.page(`${crudConfig.relativePath}/${recordId}`);
40
+ }
41
+
42
+ function resolveEditPath(recordIdLike) {
43
+ const recordId = toRouteRecordId(recordIdLike);
44
+ if (!recordId) {
45
+ return "";
46
+ }
47
+
48
+ return paths.page(`${crudConfig.relativePath}/${recordId}/edit`);
49
+ }
50
+
51
+ function scopeQueryKey() {
52
+ return crudScopeQueryKey(crudConfig.namespace);
53
+ }
54
+
55
+ async function invalidateQueries(queryClient) {
56
+ return invalidateCrudQueries(queryClient, crudConfig.namespace);
57
+ }
58
+
59
+ return Object.freeze({
60
+ route,
61
+ crudConfig,
62
+ listPath,
63
+ createPath,
64
+ listQueryKey,
65
+ viewQueryKey,
66
+ scopeQueryKey,
67
+ invalidateQueries,
68
+ formatDateTime,
69
+ resolveViewPath,
70
+ resolveEditPath
71
+ });
72
+ }
73
+
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
+ function useCrudRecordRuntime(source = {}, { recordIdParam = "recordId" } = {}) {
84
+ const normalizedRecordIdParam = normalizeRecordIdParam(recordIdParam);
85
+ const crudContext = useCrudClientContext(source);
86
+ useCrudRealtimeInvalidation(crudContext.crudConfig.namespace);
87
+ const router = useRouter();
88
+ 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));
92
+ const listPath = crudContext.listPath;
93
+
94
+ function viewQueryKey(surfaceId = "") {
95
+ return crudContext.viewQueryKey(surfaceId, recordId.value);
96
+ }
97
+
98
+ async function invalidateAndGoList(queryClient) {
99
+ await crudContext.invalidateQueries(queryClient);
100
+ if (listPath.value) {
101
+ await router.push(listPath.value);
102
+ }
103
+ }
104
+
105
+ async function invalidateAndGoView(queryClient, recordIdLike = recordId.value) {
106
+ await crudContext.invalidateQueries(queryClient);
107
+
108
+ const targetRecordId = toRouteRecordId(recordIdLike);
109
+ const targetPath = crudContext.resolveViewPath(targetRecordId || recordId.value);
110
+ if (targetPath) {
111
+ await router.push(targetPath);
112
+ }
113
+ }
114
+
115
+ return Object.freeze({
116
+ crudContext,
117
+ listPath,
118
+ recordId,
119
+ apiSuffix,
120
+ viewPath,
121
+ editPath,
122
+ viewQueryKey,
123
+ invalidateAndGoList,
124
+ invalidateAndGoView
125
+ });
126
+ }
127
+
128
+ function useCrudCreateRuntime(source = {}) {
129
+ const crudContext = useCrudClientContext(source);
130
+ useCrudRealtimeInvalidation(crudContext.crudConfig.namespace);
131
+ const router = useRouter();
132
+ const listPath = crudContext.listPath;
133
+ const apiSuffix = crudContext.crudConfig.relativePath;
134
+
135
+ function createQueryKey(surfaceId = "") {
136
+ return [...crudContext.listQueryKey(surfaceId), "create"];
137
+ }
138
+
139
+ async function invalidateAndGoView(queryClient, recordIdLike) {
140
+ await crudContext.invalidateQueries(queryClient);
141
+
142
+ const targetPath = crudContext.resolveViewPath(recordIdLike);
143
+ if (targetPath) {
144
+ await router.push(targetPath);
145
+ }
146
+ }
147
+
148
+ return Object.freeze({
149
+ crudContext,
150
+ listPath,
151
+ apiSuffix,
152
+ createQueryKey,
153
+ invalidateAndGoView
154
+ });
155
+ }
156
+
157
+ function useCrudListRuntime(source = {}) {
158
+ const crudContext = useCrudClientContext(source);
159
+ useCrudRealtimeInvalidation(crudContext.crudConfig.namespace);
160
+ const createPath = crudContext.createPath;
161
+ const apiSuffix = crudContext.crudConfig.relativePath;
162
+
163
+ function listQueryKey(surfaceId = "") {
164
+ return crudContext.listQueryKey(surfaceId);
165
+ }
166
+
167
+ return Object.freeze({
168
+ crudContext,
169
+ createPath,
170
+ apiSuffix,
171
+ listQueryKey
172
+ });
173
+ }
174
+
175
+ function createCrudClientSupport(source = {}) {
176
+ const crudConfig = resolveCrudClientConfig(source);
177
+
178
+ return Object.freeze({
179
+ useCrudClientContext() {
180
+ return useCrudClientContext(crudConfig);
181
+ },
182
+ useCrudListRuntime() {
183
+ return useCrudListRuntime(crudConfig);
184
+ },
185
+ useCrudCreateRuntime() {
186
+ return useCrudCreateRuntime(crudConfig);
187
+ },
188
+ useCrudRecordRuntime(options = {}) {
189
+ return useCrudRecordRuntime(crudConfig, options);
190
+ },
191
+ toRouteRecordId
192
+ });
193
+ }
194
+
195
+ export {
196
+ useCrudClientContext,
197
+ useCrudListRuntime,
198
+ useCrudCreateRuntime,
199
+ useCrudRecordRuntime,
200
+ createCrudClientSupport
201
+ };
@@ -0,0 +1,111 @@
1
+ import { normalizeLowerText, normalizeText, normalizeQueryToken } from "@jskit-ai/kernel/shared/support/normalize";
2
+ import { normalizeRouteVisibilityToken } from "@jskit-ai/kernel/shared/support/visibility";
3
+ import { formatDateTime } from "@jskit-ai/kernel/shared/support";
4
+
5
+ const DEFAULT_CRUD_OWNERSHIP_FILTER = "workspace";
6
+
7
+ function requireCrudNamespace(namespace, { context = "resolveCrudClientConfig" } = {}) {
8
+ const normalizedNamespace = normalizeLowerText(namespace);
9
+ if (!normalizedNamespace) {
10
+ throw new TypeError(`${context} requires a non-empty namespace.`);
11
+ }
12
+
13
+ return normalizedNamespace;
14
+ }
15
+
16
+ function normalizeRelativePath(value, { context = "resolveCrudClientConfig" } = {}) {
17
+ const raw = normalizeText(value);
18
+ if (!raw) {
19
+ throw new TypeError(`${context} requires a non-empty relative path.`);
20
+ }
21
+
22
+ const normalized = `/${raw.replace(/^\/+|\/+$/g, "")}`;
23
+ if (normalized === "/") {
24
+ throw new TypeError(`${context} requires a non-empty relative path.`);
25
+ }
26
+
27
+ return normalized;
28
+ }
29
+
30
+ function resolveCrudClientConfig(source = {}) {
31
+ const payload = source && typeof source === "object" && !Array.isArray(source) ? source : {};
32
+ const namespace = requireCrudNamespace(payload.namespace, {
33
+ context: "resolveCrudClientConfig"
34
+ });
35
+ const ownershipFilter = normalizeRouteVisibilityToken(payload.ownershipFilter, {
36
+ fallback: DEFAULT_CRUD_OWNERSHIP_FILTER
37
+ });
38
+ const inferredRelativePath = `/${namespace}`;
39
+ const relativePath = normalizeRelativePath(
40
+ Object.hasOwn(payload, "relativePath") ? payload.relativePath : inferredRelativePath,
41
+ { context: "resolveCrudClientConfig" }
42
+ );
43
+
44
+ return Object.freeze({
45
+ namespace,
46
+ ownershipFilter,
47
+ relativePath
48
+ });
49
+ }
50
+
51
+ function crudListQueryKey(surfaceId = "", workspaceSlug = "", namespace = "") {
52
+ return Object.freeze([
53
+ ...crudScopeQueryKey(namespace),
54
+ "list",
55
+ normalizeQueryToken(surfaceId),
56
+ normalizeQueryToken(workspaceSlug)
57
+ ]);
58
+ }
59
+
60
+ function crudViewQueryKey(surfaceId = "", workspaceSlug = "", recordId = 0, namespace = "") {
61
+ return Object.freeze([
62
+ ...crudScopeQueryKey(namespace),
63
+ "view",
64
+ normalizeQueryToken(surfaceId),
65
+ normalizeQueryToken(workspaceSlug),
66
+ Number(recordId) || 0
67
+ ]);
68
+ }
69
+
70
+ function crudScopeQueryKey(namespace = "") {
71
+ return Object.freeze(["crud", normalizeQueryToken(namespace)]);
72
+ }
73
+
74
+ function resolveCrudRecordChangedEvent(namespace = "") {
75
+ const normalizedNamespace = requireCrudNamespace(namespace, {
76
+ context: "resolveCrudRecordChangedEvent"
77
+ });
78
+ return `${normalizedNamespace.replace(/-/g, "_")}.record.changed`;
79
+ }
80
+
81
+ async function invalidateCrudQueries(queryClient, namespace = "") {
82
+ if (!queryClient || typeof queryClient.invalidateQueries !== "function") {
83
+ throw new TypeError("invalidateCrudQueries requires queryClient.invalidateQueries().");
84
+ }
85
+
86
+ return queryClient.invalidateQueries({
87
+ queryKey: crudScopeQueryKey(namespace)
88
+ });
89
+ }
90
+
91
+ function toRouteRecordId(value) {
92
+ if (Array.isArray(value)) {
93
+ return toRouteRecordId(value[0]);
94
+ }
95
+
96
+ const parsed = Number(value);
97
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : 0;
98
+ }
99
+
100
+ export {
101
+ DEFAULT_CRUD_OWNERSHIP_FILTER,
102
+ requireCrudNamespace,
103
+ resolveCrudClientConfig,
104
+ formatDateTime,
105
+ resolveCrudRecordChangedEvent,
106
+ crudScopeQueryKey,
107
+ invalidateCrudQueries,
108
+ crudListQueryKey,
109
+ crudViewQueryKey,
110
+ toRouteRecordId
111
+ };
@@ -0,0 +1,37 @@
1
+ import { useQueryClient } from "@tanstack/vue-query";
2
+ import { useRealtimeEvent } from "@jskit-ai/realtime/client/composables/useRealtimeEvent";
3
+ import {
4
+ crudScopeQueryKey,
5
+ requireCrudNamespace,
6
+ resolveCrudRecordChangedEvent
7
+ } from "./crudClientSupportHelpers.js";
8
+
9
+ function useCrudRealtimeInvalidation(
10
+ namespace = "",
11
+ {
12
+ event = "",
13
+ enabled = true,
14
+ matches = null,
15
+ queryKey = null
16
+ } = {}
17
+ ) {
18
+ const normalizedNamespace = requireCrudNamespace(namespace, {
19
+ context: "useCrudRealtimeInvalidation"
20
+ });
21
+ const queryClient = useQueryClient();
22
+ const resolvedEvent = String(event || "").trim() || resolveCrudRecordChangedEvent(normalizedNamespace);
23
+ const resolvedQueryKey = Array.isArray(queryKey) && queryKey.length > 0 ? queryKey : crudScopeQueryKey(normalizedNamespace);
24
+
25
+ return useRealtimeEvent({
26
+ event: resolvedEvent,
27
+ enabled,
28
+ matches,
29
+ onEvent: async () => {
30
+ await queryClient.invalidateQueries({
31
+ queryKey: resolvedQueryKey
32
+ });
33
+ }
34
+ });
35
+ }
36
+
37
+ export { useCrudRealtimeInvalidation };
@@ -0,0 +1,24 @@
1
+ export {
2
+ DEFAULT_CRUD_OWNERSHIP_FILTER,
3
+ resolveCrudClientConfig,
4
+ crudListQueryKey,
5
+ crudViewQueryKey,
6
+ crudScopeQueryKey,
7
+ invalidateCrudQueries,
8
+ formatDateTime,
9
+ resolveCrudRecordChangedEvent,
10
+ toRouteRecordId,
11
+ requireCrudNamespace
12
+ } from "./composables/crudClientSupportHelpers.js";
13
+
14
+ export {
15
+ useCrudClientContext,
16
+ useCrudListRuntime,
17
+ useCrudCreateRuntime,
18
+ useCrudRecordRuntime,
19
+ createCrudClientSupport
20
+ } from "./composables/createCrudClientSupport.js";
21
+
22
+ export {
23
+ useCrudRealtimeInvalidation
24
+ } from "./composables/useCrudRealtimeInvalidation.js";
@@ -0,0 +1,29 @@
1
+ import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
2
+
3
+ const DEFAULT_LIST_LIMIT = 20;
4
+ const MAX_LIST_LIMIT = 100;
5
+
6
+ function normalizeCrudListLimit(value, { fallback = DEFAULT_LIST_LIMIT, max = MAX_LIST_LIMIT } = {}) {
7
+ const parsed = Number(value);
8
+ if (!Number.isInteger(parsed) || parsed < 1) {
9
+ return fallback;
10
+ }
11
+
12
+ return Math.min(parsed, max);
13
+ }
14
+
15
+ function requireCrudTableName(tableName, { context = "crudRepository" } = {}) {
16
+ const normalizedTableName = normalizeText(tableName);
17
+ if (!normalizedTableName) {
18
+ throw new TypeError(`${context} requires tableName.`);
19
+ }
20
+
21
+ return normalizedTableName;
22
+ }
23
+
24
+ export {
25
+ DEFAULT_LIST_LIMIT,
26
+ MAX_LIST_LIMIT,
27
+ normalizeCrudListLimit,
28
+ requireCrudTableName
29
+ };
@@ -0,0 +1,90 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import {
4
+ resolveCrudClientConfig,
5
+ crudScopeQueryKey,
6
+ invalidateCrudQueries,
7
+ crudListQueryKey,
8
+ crudViewQueryKey,
9
+ toRouteRecordId,
10
+ resolveCrudRecordChangedEvent
11
+ } from "../src/client/composables/crudClientSupportHelpers.js";
12
+
13
+ test("resolveCrudClientConfig normalizes namespace, ownership filter, and derives relativePath", () => {
14
+ const config = resolveCrudClientConfig({
15
+ namespace: " Customers ",
16
+ ownershipFilter: "workspace",
17
+ relativePath: "/crm/customers"
18
+ });
19
+
20
+ assert.deepEqual(config, {
21
+ namespace: "customers",
22
+ ownershipFilter: "workspace",
23
+ relativePath: "/crm/customers"
24
+ });
25
+ });
26
+
27
+ test("resolveCrudClientConfig infers default relativePath from namespace", () => {
28
+ const config = resolveCrudClientConfig({
29
+ namespace: "appointments",
30
+ ownershipFilter: "public"
31
+ });
32
+
33
+ assert.equal(config.relativePath, "/appointments");
34
+ });
35
+
36
+ test("resolveCrudClientConfig throws when namespace is missing", () => {
37
+ assert.throws(
38
+ () => resolveCrudClientConfig({ ownershipFilter: "workspace" }),
39
+ /requires a non-empty namespace/
40
+ );
41
+ });
42
+
43
+ test("crudListQueryKey and crudViewQueryKey normalize cache keys", () => {
44
+ assert.deepEqual(crudListQueryKey("Admin", " TonymoBily3 ", "Customers"), [
45
+ "crud",
46
+ "customers",
47
+ "list",
48
+ "admin",
49
+ "tonymobily3"
50
+ ]);
51
+
52
+ assert.deepEqual(crudViewQueryKey("Admin", " TonymoBily3 ", "12", "Customers"), [
53
+ "crud",
54
+ "customers",
55
+ "view",
56
+ "admin",
57
+ "tonymobily3",
58
+ 12
59
+ ]);
60
+ });
61
+
62
+ test("crudScopeQueryKey normalizes namespace", () => {
63
+ assert.deepEqual(crudScopeQueryKey(" Customers "), ["crud", "customers"]);
64
+ });
65
+
66
+ test("invalidateCrudQueries invalidates by CRUD namespace scope key", async () => {
67
+ let payload = null;
68
+ const queryClient = {
69
+ async invalidateQueries(input) {
70
+ payload = input;
71
+ return true;
72
+ }
73
+ };
74
+
75
+ await invalidateCrudQueries(queryClient, "Customers");
76
+ assert.deepEqual(payload, {
77
+ queryKey: ["crud", "customers"]
78
+ });
79
+ });
80
+
81
+ test("toRouteRecordId parses scalar and array params safely", () => {
82
+ assert.equal(toRouteRecordId("42"), 42);
83
+ assert.equal(toRouteRecordId(["7"]), 7);
84
+ assert.equal(toRouteRecordId("not-a-number"), 0);
85
+ });
86
+
87
+ test("resolveCrudRecordChangedEvent normalizes namespace into event channel", () => {
88
+ assert.equal(resolveCrudRecordChangedEvent("Customers"), "customers.record.changed");
89
+ assert.equal(resolveCrudRecordChangedEvent("customer-orders"), "customer_orders.record.changed");
90
+ });
@@ -0,0 +1,24 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import {
4
+ DEFAULT_LIST_LIMIT,
5
+ normalizeCrudListLimit,
6
+ requireCrudTableName
7
+ } from "../src/server/repositorySupport.js";
8
+
9
+ test("normalizeCrudListLimit enforces fallback and max", () => {
10
+ assert.equal(normalizeCrudListLimit(null), DEFAULT_LIST_LIMIT);
11
+ assert.equal(normalizeCrudListLimit("abc"), DEFAULT_LIST_LIMIT);
12
+ assert.equal(normalizeCrudListLimit(0), DEFAULT_LIST_LIMIT);
13
+ assert.equal(normalizeCrudListLimit(5), 5);
14
+ assert.equal(normalizeCrudListLimit(200), 100);
15
+ });
16
+
17
+ test("requireCrudTableName trims and rejects empty values", () => {
18
+ assert.equal(requireCrudTableName(" crud_customers "), "crud_customers");
19
+
20
+ assert.throws(
21
+ () => requireCrudTableName(" "),
22
+ /requires tableName/
23
+ );
24
+ });