target-supabase-sdk 0.1.7 → 0.1.8
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/dist/auth/auth.api.js +6 -6
- package/dist/auth/auth.api.js.map +1 -1
- package/dist/core.api.d.ts +46 -6
- package/dist/core.api.d.ts.map +1 -1
- package/dist/core.api.js +105 -97
- package/dist/core.api.js.map +1 -1
- package/dist/core.interface.d.ts +2 -1
- package/dist/core.interface.d.ts.map +1 -1
- package/dist/core.interface.js +1 -1
- package/dist/core.interface.js.map +1 -1
- package/dist/core.utils.d.ts +0 -2
- package/dist/core.utils.d.ts.map +1 -1
- package/dist/core.utils.js +0 -5
- package/dist/core.utils.js.map +1 -1
- package/dist/file-list/file-list.api.js +1 -1
- package/dist/file-list/file-list.api.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/list/list.interface.d.ts +2 -2
- package/dist/list/list.interface.d.ts.map +1 -1
- package/dist/node/node.api.d.ts +32 -0
- package/dist/node/node.api.d.ts.map +1 -0
- package/dist/node/node.api.js +137 -0
- package/dist/node/node.api.js.map +1 -0
- package/dist/node/node.interface.d.ts +28 -0
- package/dist/node/node.interface.d.ts.map +1 -0
- package/dist/node/node.interface.js +17 -0
- package/dist/node/node.interface.js.map +1 -0
- package/dist/question-list/question-list.api.js +1 -1
- package/dist/question-list/question-list.api.js.map +1 -1
- package/dist/repo/repo.interface.d.ts +20 -0
- package/dist/repo/repo.interface.d.ts.map +1 -0
- package/dist/repo/repo.interface.js +8 -0
- package/dist/repo/repo.interface.js.map +1 -0
- package/dist/review/review.api.js +5 -5
- package/dist/review/review.api.js.map +1 -1
- package/dist/task/task-node-manager.d.ts +1 -0
- package/dist/task/task-node-manager.d.ts.map +1 -0
- package/dist/task/task-node-manager.js +2 -0
- package/dist/task/task-node-manager.js.map +1 -0
- package/dist/task/task.api.d.ts +1 -0
- package/dist/task/task.api.d.ts.map +1 -0
- package/dist/task/task.api.js +2 -0
- package/dist/task/task.api.js.map +1 -0
- package/dist/task/task.interface.d.ts +49 -0
- package/dist/task/task.interface.d.ts.map +1 -0
- package/dist/task/task.interface.js +26 -0
- package/dist/task/task.interface.js.map +1 -0
- package/dist/task/task.utils.d.ts +1 -0
- package/dist/task/task.utils.d.ts.map +1 -0
- package/dist/task/task.utils.js +2 -0
- package/dist/task/task.utils.js.map +1 -0
- package/dist/word/word.api.d.ts +5 -5
- package/dist/word/word.api.d.ts.map +1 -1
- package/dist/word/word.api.js +1 -1
- package/dist/word/word.api.js.map +1 -1
- package/package.json +1 -1
- package/src/auth/auth.api.ts +6 -6
- package/src/core.api.ts +194 -117
- package/src/core.interface.ts +3 -1
- package/src/core.utils.ts +0 -6
- package/src/file-list/file-list.api.ts +1 -1
- package/src/index.ts +6 -0
- package/src/list/list.interface.ts +2 -2
- package/src/node/node.api.ts +161 -0
- package/src/node/node.interface.ts +32 -0
- package/src/question-list/question-list.api.ts +1 -1
- package/src/repo/repo.interface.ts +23 -0
- package/src/review/review.api.ts +5 -5
- package/src/task/task-node-manager.ts +0 -0
- package/src/task/task.api.ts +0 -0
- package/src/task/task.interface.ts +60 -0
- package/src/task/task.utils.ts +0 -0
- package/src/word/word.api.ts +6 -6
package/src/auth/auth.api.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { supabase } from "..";
|
|
2
|
-
import {
|
|
2
|
+
import { generateResponse } from "../core.interface";
|
|
3
3
|
import { handleSupabaseError } from "../core.utils";
|
|
4
4
|
|
|
5
5
|
export interface LoginPayload {
|
|
@@ -31,7 +31,7 @@ export const registerUser = async ({ email, password }: RegisterPayload) => {
|
|
|
31
31
|
handleSupabaseError("registerUser", error, "Registration failed. Please try again.");
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
return
|
|
34
|
+
return generateResponse.success(data);
|
|
35
35
|
};
|
|
36
36
|
|
|
37
37
|
// 用户登录
|
|
@@ -45,7 +45,7 @@ export const loginUser = async ({ email, password }: LoginPayload) => {
|
|
|
45
45
|
handleSupabaseError("loginUser", error, "Login failed. Please check your credentials.");
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
return
|
|
48
|
+
return generateResponse.success(data);
|
|
49
49
|
};
|
|
50
50
|
|
|
51
51
|
// 用户登出
|
|
@@ -56,7 +56,7 @@ export const logoutUser = async () => {
|
|
|
56
56
|
handleSupabaseError("logoutUser", error, "Logout failed.");
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
return
|
|
59
|
+
return generateResponse.success();
|
|
60
60
|
};
|
|
61
61
|
|
|
62
62
|
// 获取当前用户
|
|
@@ -70,7 +70,7 @@ export const getCurrentUser = async () => {
|
|
|
70
70
|
handleSupabaseError("getCurrentUser", error, "Failed to get current user.");
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
return
|
|
73
|
+
return generateResponse.success<User | null>(user as User | null);
|
|
74
74
|
};
|
|
75
75
|
|
|
76
76
|
// 重置密码
|
|
@@ -81,5 +81,5 @@ export const resetPassword = async (email: string) => {
|
|
|
81
81
|
handleSupabaseError("resetPassword", error, "Password reset request failed.");
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
return
|
|
84
|
+
return generateResponse.success();
|
|
85
85
|
};
|
package/src/core.api.ts
CHANGED
|
@@ -1,15 +1,39 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { createResponse, Target } from "./core.interface";
|
|
1
|
+
import { generateResponse, Target, TargetPayload } from "./core.interface";
|
|
3
2
|
import { supabase } from ".";
|
|
4
3
|
import { BaseValidator, handleSupabaseError } from "./core.utils";
|
|
5
4
|
import { PostgrestFilterBuilder } from "@supabase/postgrest-js";
|
|
6
5
|
|
|
7
6
|
export interface QueryFilter {
|
|
8
7
|
field: string;
|
|
9
|
-
operator: "eq";
|
|
8
|
+
operator: "eq" | "neq" | "in";
|
|
10
9
|
value: unknown;
|
|
11
10
|
}
|
|
12
11
|
|
|
12
|
+
// biome-ignore lint/suspicious/noExplicitAny: PostgrestFilterBuilder is generic over schema
|
|
13
|
+
function applyQueryFilter<Q extends PostgrestFilterBuilder<any, any, any, any, any>>(
|
|
14
|
+
query: Q,
|
|
15
|
+
filter: QueryFilter
|
|
16
|
+
): Q {
|
|
17
|
+
switch (filter.operator) {
|
|
18
|
+
case "eq":
|
|
19
|
+
return query.eq(filter.field, filter.value) as Q;
|
|
20
|
+
case "neq":
|
|
21
|
+
return query.neq(filter.field, filter.value) as Q;
|
|
22
|
+
case "in":
|
|
23
|
+
return query.in(filter.field, filter.value as unknown[]) as Q;
|
|
24
|
+
default:
|
|
25
|
+
return query;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// biome-ignore lint/suspicious/noExplicitAny: PostgrestFilterBuilder is generic over schema
|
|
30
|
+
function applyQueryFilters<Q extends PostgrestFilterBuilder<any, any, any, any, any>>(
|
|
31
|
+
query: Q,
|
|
32
|
+
filterList: QueryFilter[]
|
|
33
|
+
): Q {
|
|
34
|
+
return filterList.reduce((q, filter) => applyQueryFilter(q, filter), query);
|
|
35
|
+
}
|
|
36
|
+
|
|
13
37
|
export interface QueryOrderBy {
|
|
14
38
|
field: string;
|
|
15
39
|
ascending: boolean;
|
|
@@ -23,38 +47,24 @@ export interface BaseQueryParams {
|
|
|
23
47
|
export const getTarget = async ({ id, filterList }: { id: string; filterList?: QueryFilter[] }) => {
|
|
24
48
|
const query =
|
|
25
49
|
filterList != null && filterList.length > 0
|
|
26
|
-
?
|
|
27
|
-
switch (filter.operator) {
|
|
28
|
-
case "eq":
|
|
29
|
-
return query.eq(filter.field, filter.value);
|
|
30
|
-
default:
|
|
31
|
-
return query;
|
|
32
|
-
}
|
|
33
|
-
}, supabase.client.from("target").select().eq("id", id))
|
|
50
|
+
? applyQueryFilters(supabase.client.from("target").select().eq("id", id), filterList)
|
|
34
51
|
: supabase.client.from("target").select().eq("id", id);
|
|
35
52
|
|
|
36
53
|
const { data, error } = await query.single();
|
|
37
54
|
if (error) {
|
|
38
55
|
handleSupabaseError("getTarget", error, "Failed to fetch target.");
|
|
39
56
|
}
|
|
40
|
-
return
|
|
57
|
+
return generateResponse.success<Target>(data as Target);
|
|
41
58
|
};
|
|
42
59
|
|
|
43
60
|
export const getPossibleTarget = async ({ filterList }: { filterList: QueryFilter[] }) => {
|
|
44
|
-
const query =
|
|
45
|
-
switch (filter.operator) {
|
|
46
|
-
case "eq":
|
|
47
|
-
return query.eq(filter.field, filter.value);
|
|
48
|
-
default:
|
|
49
|
-
return query;
|
|
50
|
-
}
|
|
51
|
-
}, supabase.client.from("target").select());
|
|
61
|
+
const query = applyQueryFilters(supabase.client.from("target").select(), filterList);
|
|
52
62
|
|
|
53
63
|
const { data, error } = await query.maybeSingle();
|
|
54
64
|
if (error) {
|
|
55
65
|
handleSupabaseError("getPossibleTarget", error, "Failed to fetch target.");
|
|
56
66
|
}
|
|
57
|
-
return
|
|
67
|
+
return generateResponse.success<Target | null>(data);
|
|
58
68
|
};
|
|
59
69
|
|
|
60
70
|
export interface GetTargetListParams extends BaseQueryParams {
|
|
@@ -71,19 +81,12 @@ export const getTargetList = async ({
|
|
|
71
81
|
orderBy = { field: "created_at", ascending: false },
|
|
72
82
|
filterBuilder,
|
|
73
83
|
}: GetTargetListParams & {
|
|
74
|
-
// biome-ignore lint/suspicious/noExplicitAny:
|
|
84
|
+
// biome-ignore lint/suspicious/noExplicitAny: PostgrestFilterBuilder is generic over schema
|
|
75
85
|
filterBuilder?: PostgrestFilterBuilder<any, any, any[], "target", unknown>;
|
|
76
86
|
}) => {
|
|
77
87
|
const query =
|
|
78
88
|
filterBuilder ??
|
|
79
|
-
|
|
80
|
-
switch (filter.operator) {
|
|
81
|
-
case "eq":
|
|
82
|
-
return query.eq(filter.field, filter.value);
|
|
83
|
-
default:
|
|
84
|
-
return query;
|
|
85
|
-
}
|
|
86
|
-
}, supabase.client.from("target").select("*").eq("category", category));
|
|
89
|
+
applyQueryFilters(supabase.client.from("target").select("*").eq("category", category), filterList);
|
|
87
90
|
|
|
88
91
|
const { data, error } = await query
|
|
89
92
|
.order(orderBy.field, { ascending: orderBy.ascending })
|
|
@@ -92,15 +95,14 @@ export const getTargetList = async ({
|
|
|
92
95
|
if (error) {
|
|
93
96
|
handleSupabaseError("getTargetList", error, "Failed to fetch target list.");
|
|
94
97
|
}
|
|
95
|
-
return
|
|
98
|
+
return generateResponse.success<Target[]>(data);
|
|
96
99
|
};
|
|
97
100
|
|
|
98
101
|
export interface GetTargetTotalCountParams extends BaseQueryParams {
|
|
99
102
|
category: Target["category"];
|
|
100
|
-
// biome-ignore lint/suspicious/noExplicitAny:
|
|
103
|
+
// biome-ignore lint/suspicious/noExplicitAny: PostgrestFilterBuilder is generic over schema
|
|
101
104
|
filterBuilder?: PostgrestFilterBuilder<any, any, any[], "target", unknown>;
|
|
102
105
|
}
|
|
103
|
-
|
|
104
106
|
export const getTargetTotalCount = async ({
|
|
105
107
|
category,
|
|
106
108
|
filterList = [],
|
|
@@ -108,19 +110,12 @@ export const getTargetTotalCount = async ({
|
|
|
108
110
|
}: GetTargetTotalCountParams) => {
|
|
109
111
|
const query =
|
|
110
112
|
filterBuilder ??
|
|
111
|
-
|
|
112
|
-
(query, filter) => {
|
|
113
|
-
switch (filter.operator) {
|
|
114
|
-
case "eq":
|
|
115
|
-
return query.eq(filter.field, filter.value);
|
|
116
|
-
default:
|
|
117
|
-
return query;
|
|
118
|
-
}
|
|
119
|
-
},
|
|
113
|
+
applyQueryFilters(
|
|
120
114
|
supabase.client
|
|
121
115
|
.from("target")
|
|
122
|
-
.select("id", { count: "exact", head: true })
|
|
123
|
-
.eq("category", category)
|
|
116
|
+
.select("id", { count: "exact", head: true })
|
|
117
|
+
.eq("category", category),
|
|
118
|
+
filterList
|
|
124
119
|
);
|
|
125
120
|
|
|
126
121
|
const { count, error } = await query;
|
|
@@ -128,20 +123,13 @@ export const getTargetTotalCount = async ({
|
|
|
128
123
|
if (error) {
|
|
129
124
|
handleSupabaseError("getTargetTotalCount", error, "Failed to fetch target count.");
|
|
130
125
|
}
|
|
131
|
-
return
|
|
126
|
+
return generateResponse.success<number>(count ?? 0);
|
|
132
127
|
};
|
|
133
128
|
|
|
134
129
|
export const deleteTarget = async ({ id, filterList }: { id: string; filterList?: QueryFilter[] }) => {
|
|
135
130
|
// Check whether the target exists given filterList
|
|
136
131
|
if (filterList != null && filterList.length > 0) {
|
|
137
|
-
const query =
|
|
138
|
-
switch (filter.operator) {
|
|
139
|
-
case "eq":
|
|
140
|
-
return query.eq(filter.field, filter.value);
|
|
141
|
-
default:
|
|
142
|
-
return query;
|
|
143
|
-
}
|
|
144
|
-
}, supabase.client.from("target").select().eq("id", id));
|
|
132
|
+
const query = applyQueryFilters(supabase.client.from("target").select().eq("id", id), filterList);
|
|
145
133
|
const { data: existingTarget, error: existingError } = await query.single();
|
|
146
134
|
if (existingError || existingTarget == null) {
|
|
147
135
|
const msg = `[deleteTarget] Cannot find the target ${id}`;
|
|
@@ -153,7 +141,7 @@ export const deleteTarget = async ({ id, filterList }: { id: string; filterList?
|
|
|
153
141
|
if (error) {
|
|
154
142
|
handleSupabaseError("deleteTarget", error, `Failed to delete target ${id}.`);
|
|
155
143
|
}
|
|
156
|
-
return
|
|
144
|
+
return generateResponse.success();
|
|
157
145
|
};
|
|
158
146
|
|
|
159
147
|
export interface PostTargetPayload {
|
|
@@ -172,7 +160,7 @@ class PostTargetPayloadValidator extends BaseValidator<PostTargetPayload> {
|
|
|
172
160
|
constructor() {
|
|
173
161
|
super();
|
|
174
162
|
// Add custom
|
|
175
|
-
this.
|
|
163
|
+
this.addCustomValidator((val) => {
|
|
176
164
|
return true;
|
|
177
165
|
});
|
|
178
166
|
}
|
|
@@ -190,7 +178,7 @@ export const postTarget = async (payload: PostTargetPayload) => {
|
|
|
190
178
|
if (error) {
|
|
191
179
|
handleSupabaseError("postTarget", error, "Failed to create target.");
|
|
192
180
|
}
|
|
193
|
-
return
|
|
181
|
+
return generateResponse.success<Target>(data as Target);
|
|
194
182
|
};
|
|
195
183
|
|
|
196
184
|
export interface PatchTargetPayload extends PostTargetPayload {
|
|
@@ -218,25 +206,55 @@ export const patchTarget = async ({ id, ...restPayload }: PatchTargetPayload) =>
|
|
|
218
206
|
if (error) {
|
|
219
207
|
handleSupabaseError("patchTarget", error, "Failed to update target.");
|
|
220
208
|
}
|
|
221
|
-
return
|
|
209
|
+
return generateResponse.success<Target>(data as Target);
|
|
222
210
|
};
|
|
223
211
|
|
|
212
|
+
/**
|
|
213
|
+
* Read-modify-write `target.details` with optional DB-level optimistic locking on UPDATE.
|
|
214
|
+
*
|
|
215
|
+
* Do not use application-layer pre-update validators (e.g. checking state after SELECT then
|
|
216
|
+
* UPDATE with only `id`). That pattern is not atomic and loses under concurrency. Pass expected
|
|
217
|
+
* row conditions via `optimisticLockFilterList` so they are applied on the UPDATE statement.
|
|
218
|
+
*/
|
|
219
|
+
export interface UpdateTargetDetailsParams<D> {
|
|
220
|
+
/** Target row id. */
|
|
221
|
+
id: string;
|
|
222
|
+
/**
|
|
223
|
+
* Computes new details from the row fetched immediately before UPDATE.
|
|
224
|
+
* Runs after SELECT; the write still relies on `optimisticLockFilterList` for atomic guards.
|
|
225
|
+
*/
|
|
226
|
+
updateFn: (existing: D) => D;
|
|
227
|
+
/**
|
|
228
|
+
* Optional `target.extra` derived from existing details (written together with details).
|
|
229
|
+
*/
|
|
230
|
+
updateExtraFn?: (existing: D) => string;
|
|
231
|
+
/**
|
|
232
|
+
* Optimistic lock conditions applied on UPDATE (not on the prior SELECT).
|
|
233
|
+
*
|
|
234
|
+
* Replaces the removed `beforeUpdateValidator`: validators ran in app code between read and
|
|
235
|
+
* write and could not prevent concurrent overwrites. These filters become part of the UPDATE
|
|
236
|
+
* WHERE clause (via `applyQueryFilters`), e.g. `{ field: "details->>status", operator: "eq", value: "TODO" }`.
|
|
237
|
+
*
|
|
238
|
+
* If no row matches after UPDATE, throws "Optimistic lock failed". Empty array = update by id only.
|
|
239
|
+
*/
|
|
240
|
+
optimisticLockFilterList?: QueryFilter[];
|
|
241
|
+
}
|
|
242
|
+
|
|
224
243
|
export const updateTargetDetails = async <T, D>({
|
|
225
244
|
id,
|
|
226
245
|
updateFn,
|
|
227
|
-
beforeUpdateValidator = () => true,
|
|
228
246
|
updateExtraFn,
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
updateFn: (existing: D) => D;
|
|
232
|
-
beforeUpdateValidator?: (existing: D) => true | string;
|
|
233
|
-
updateExtraFn?: (existing: D) => string;
|
|
234
|
-
}) => {
|
|
247
|
+
optimisticLockFilterList = [],
|
|
248
|
+
}: UpdateTargetDetailsParams<D>) => {
|
|
235
249
|
const { data: currentData, error: fetchError } = await supabase.client
|
|
236
250
|
.from("target")
|
|
237
251
|
.select("details")
|
|
238
252
|
.eq("id", id)
|
|
239
253
|
.single();
|
|
254
|
+
|
|
255
|
+
if (fetchError) {
|
|
256
|
+
handleSupabaseError("updateTargetDetails", fetchError, "Failed to fetch target.");
|
|
257
|
+
}
|
|
240
258
|
if (!currentData) {
|
|
241
259
|
const msg = "[updateTargetDetails] Target NOT exists: " + id;
|
|
242
260
|
console.error(msg);
|
|
@@ -244,30 +262,85 @@ export const updateTargetDetails = async <T, D>({
|
|
|
244
262
|
}
|
|
245
263
|
|
|
246
264
|
const currentDetails = currentData.details as D;
|
|
247
|
-
|
|
248
|
-
// Check before update
|
|
249
|
-
const validatorResult = beforeUpdateValidator(currentDetails);
|
|
250
|
-
if (validatorResult !== true) {
|
|
251
|
-
throw new Error("[updateTargetDetails] validation failed. " + validatorResult);
|
|
252
|
-
}
|
|
253
|
-
|
|
254
265
|
const updatedDetails: D = updateFn(currentDetails);
|
|
255
266
|
const updated =
|
|
256
267
|
updateExtraFn == null
|
|
257
268
|
? {
|
|
258
|
-
|
|
259
|
-
|
|
269
|
+
details: updatedDetails,
|
|
270
|
+
}
|
|
260
271
|
: {
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
272
|
+
details: updatedDetails,
|
|
273
|
+
extra: updateExtraFn(currentDetails),
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const updateQuery = applyQueryFilters(
|
|
277
|
+
supabase.client.from("target").update(updated).eq("id", id),
|
|
278
|
+
optimisticLockFilterList
|
|
279
|
+
);
|
|
264
280
|
|
|
265
|
-
const { data, error } = await
|
|
281
|
+
const { data, error } = await updateQuery.select().maybeSingle();
|
|
282
|
+
|
|
283
|
+
if (error) {
|
|
284
|
+
handleSupabaseError("updateTargetDetails", error, "Failed to update target details.");
|
|
285
|
+
}
|
|
286
|
+
if (!data) {
|
|
287
|
+
const msg =
|
|
288
|
+
optimisticLockFilterList.length > 0
|
|
289
|
+
? OPTIMISTIC_LOCK_FAILED_MESSAGE
|
|
290
|
+
: "[updateTargetDetails] Target not found or was deleted.";
|
|
291
|
+
throw new Error(msg);
|
|
292
|
+
}
|
|
266
293
|
|
|
267
|
-
if (error) handleSupabaseError("updateTargetDetails", error, "Failed to update target details.");
|
|
268
294
|
return data as T;
|
|
269
295
|
};
|
|
270
296
|
|
|
297
|
+
export const OPTIMISTIC_LOCK_FAILED_MESSAGE =
|
|
298
|
+
"[updateTargetDetails] Optimistic lock failed: target no longer matches expected state.";
|
|
299
|
+
|
|
300
|
+
export const CREATE_TARGET_ALREADY_EXISTS_MESSAGE = "[createTarget] Target already exists";
|
|
301
|
+
|
|
302
|
+
const CREATE_TARGET_REDUNDANCY_MISMATCH_MESSAGE =
|
|
303
|
+
"[createTarget] Inserted row does not match checkRedundancyFilterList; verify createFn aligns with filters.";
|
|
304
|
+
|
|
305
|
+
/** Max rows to fetch during post-verify — only need to detect one other match. */
|
|
306
|
+
const REDUNDANCY_VERIFY_ROW_LIMIT = 2;
|
|
307
|
+
|
|
308
|
+
export function isOptimisticLockError(error: unknown): boolean {
|
|
309
|
+
return error instanceof Error && error.message === OPTIMISTIC_LOCK_FAILED_MESSAGE;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export function isCreateTargetAlreadyExistsError(error: unknown): boolean {
|
|
313
|
+
return error instanceof Error && error.message === CREATE_TARGET_ALREADY_EXISTS_MESSAGE;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Roll back an optimistic insert when redundancy filters match other rows. */
|
|
317
|
+
async function rollbackCreateTargetInsert(id: string) {
|
|
318
|
+
const { error } = await supabase.client.from("target").delete().eq("id", id);
|
|
319
|
+
if (error) {
|
|
320
|
+
handleSupabaseError("createTarget", error, "Failed to rollback conflicting insert.");
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Roll back the new row and signal a redundancy conflict to the caller. */
|
|
325
|
+
function throwCreateTargetAlreadyExists(checkRedundancyFilterList: QueryFilter[]): never {
|
|
326
|
+
console.error(CREATE_TARGET_ALREADY_EXISTS_MESSAGE, checkRedundancyFilterList);
|
|
327
|
+
throw new Error(CREATE_TARGET_ALREADY_EXISTS_MESSAGE);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async function failCreateTargetRedundancy(
|
|
331
|
+
selfId: string,
|
|
332
|
+
checkRedundancyFilterList: QueryFilter[]
|
|
333
|
+
): Promise<never> {
|
|
334
|
+
await rollbackCreateTargetInsert(selfId);
|
|
335
|
+
return throwCreateTargetAlreadyExists(checkRedundancyFilterList);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Create a `target` row with optional payload validation and optimistic redundancy checks.
|
|
340
|
+
*
|
|
341
|
+
* When `checkRedundancyFilterList` is set, uses insert-first + post-verify (not SELECT-before-INSERT).
|
|
342
|
+
* See `.cursor/skills/create-target-redundancy/SKILL.md`.
|
|
343
|
+
*/
|
|
271
344
|
export const createTarget = async <T extends Target, P extends object>({
|
|
272
345
|
payload,
|
|
273
346
|
validator,
|
|
@@ -277,55 +350,59 @@ export const createTarget = async <T extends Target, P extends object>({
|
|
|
277
350
|
}: {
|
|
278
351
|
payload: P;
|
|
279
352
|
validator?: new () => BaseValidator<P>;
|
|
280
|
-
createFn: (validPayload: P) =>
|
|
353
|
+
createFn: (validPayload: P) => TargetPayload<T>;
|
|
354
|
+
/** Business-key filters; if another row matches after insert, conflict handling runs. */
|
|
281
355
|
checkRedundancyFilterList?: QueryFilter[];
|
|
356
|
+
/**
|
|
357
|
+
* @deprecated Not supported — ignored at runtime. Use domain patch APIs or future RPC upsert.
|
|
358
|
+
* Reserved for a later release; do not pass `true`.
|
|
359
|
+
*/
|
|
282
360
|
upsert?: boolean;
|
|
283
361
|
}) => {
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
// (Optional)Step 2: check redundancy
|
|
288
|
-
if (checkRedundancyFilterList != null && checkRedundancyFilterList.length > 0) {
|
|
289
|
-
const query = checkRedundancyFilterList.reduce((query, filter) => {
|
|
290
|
-
switch (filter.operator) {
|
|
291
|
-
case "eq":
|
|
292
|
-
return query.eq(filter.field, filter.value);
|
|
293
|
-
default:
|
|
294
|
-
return query;
|
|
295
|
-
}
|
|
296
|
-
}, supabase.client.from("target").select("id"));
|
|
297
|
-
const { data: existingTarget, error: existingError } = await query.maybeSingle();
|
|
298
|
-
if (existingError) {
|
|
299
|
-
handleSupabaseError("createTarget", existingError, "Failed to check target existence.");
|
|
300
|
-
}
|
|
301
|
-
if (existingTarget) {
|
|
302
|
-
if (upsert) {
|
|
303
|
-
const newTarget = createFn(validPayload);
|
|
304
|
-
const updated = pick(newTarget, ["category", "name", "value", "tagList", "extra", "details"]);
|
|
305
|
-
const { data, error } = await supabase.client
|
|
306
|
-
.from("target")
|
|
307
|
-
.update(updated)
|
|
308
|
-
.eq("id", existingTarget.id)
|
|
309
|
-
.select()
|
|
310
|
-
.single();
|
|
311
|
-
if (error) {
|
|
312
|
-
handleSupabaseError("createTarget", error, "Failed to update existing target.");
|
|
313
|
-
}
|
|
314
|
-
return createResponse.success<T>(data);
|
|
315
|
-
}
|
|
316
|
-
const msg = "[createTarget] Target already exists";
|
|
317
|
-
console.error(msg, checkRedundancyFilterList);
|
|
318
|
-
throw new Error(msg);
|
|
319
|
-
}
|
|
362
|
+
if (upsert) {
|
|
363
|
+
console.warn("[createTarget] `upsert` is deprecated and has no effect.");
|
|
320
364
|
}
|
|
321
365
|
|
|
322
|
-
|
|
366
|
+
const validPayload = validator != null ? new validator().validate(payload) : payload;
|
|
323
367
|
const newTarget = createFn(validPayload);
|
|
368
|
+
|
|
369
|
+
const hasRedundancyCheck =
|
|
370
|
+
checkRedundancyFilterList != null && checkRedundancyFilterList.length > 0;
|
|
371
|
+
|
|
372
|
+
// 1. Optimistic insert (see create-target-redundancy skill)
|
|
324
373
|
const { data, error } = await supabase.client.from("target").insert([newTarget]).select().single();
|
|
325
374
|
if (error) {
|
|
326
375
|
handleSupabaseError("createTarget", error, "Failed to create target.");
|
|
327
376
|
}
|
|
328
|
-
|
|
377
|
+
|
|
378
|
+
if (!hasRedundancyCheck) {
|
|
379
|
+
return generateResponse.success<T>(data);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// 2. Post-verify: limit(2) — enough to detect one other row matching the business-key filters
|
|
383
|
+
const { data: matches, error: matchError } = await applyQueryFilters(
|
|
384
|
+
supabase.client.from("target").select("id"),
|
|
385
|
+
checkRedundancyFilterList
|
|
386
|
+
).limit(REDUNDANCY_VERIFY_ROW_LIMIT);
|
|
387
|
+
if (matchError) {
|
|
388
|
+
handleSupabaseError("createTarget", matchError, "Failed to verify target redundancy.");
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const selfId = data.id as string;
|
|
392
|
+
const rows = matches ?? [];
|
|
393
|
+
const selfMatchesFilters = rows.some((row) => row.id === selfId);
|
|
394
|
+
if (!selfMatchesFilters) {
|
|
395
|
+
console.warn(CREATE_TARGET_REDUNDANCY_MISMATCH_MESSAGE, checkRedundancyFilterList);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const hasOtherRow = rows.some((row) => row.id !== selfId);
|
|
399
|
+
|
|
400
|
+
// 3. No conflict — keep the inserted row; otherwise rollback and reject
|
|
401
|
+
if (!hasOtherRow) {
|
|
402
|
+
return generateResponse.success<T>(data);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return failCreateTargetRedundancy(selfId, checkRedundancyFilterList);
|
|
329
406
|
};
|
|
330
407
|
|
|
331
408
|
export function validateWith<P extends object, V extends BaseValidator<P>>(ValidatorClass: new () => V) {
|
package/src/core.interface.ts
CHANGED
|
@@ -11,6 +11,8 @@ export interface Target {
|
|
|
11
11
|
created_at: string;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
export type TargetPayload<T extends Target> = Omit<T, "id" | "created_at">;
|
|
15
|
+
|
|
14
16
|
export enum StatusCode {
|
|
15
17
|
SUCCESS = 200,
|
|
16
18
|
ERROR = 400,
|
|
@@ -27,7 +29,7 @@ export interface SupabaseResponse<T = unknown> {
|
|
|
27
29
|
};
|
|
28
30
|
}
|
|
29
31
|
|
|
30
|
-
export const
|
|
32
|
+
export const generateResponse = {
|
|
31
33
|
success<T>(data?: T, message?: string): SupabaseResponse<T> {
|
|
32
34
|
return {
|
|
33
35
|
status_code: StatusCode.SUCCESS,
|
package/src/core.utils.ts
CHANGED
|
@@ -42,12 +42,6 @@ export abstract class BaseValidator<T extends object> {
|
|
|
42
42
|
protected ignoredFields: (keyof T)[] = [];
|
|
43
43
|
protected customValidators: ((val: T) => true | string)[] = [];
|
|
44
44
|
|
|
45
|
-
/** @deprecated -> addCustomValidator */
|
|
46
|
-
addValidator(validator: (val: T) => true | string) {
|
|
47
|
-
this.customValidators.push(validator);
|
|
48
|
-
return this;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
45
|
addCustomValidator(validator: (val: T) => true | string) {
|
|
52
46
|
this.customValidators.push(validator);
|
|
53
47
|
return this;
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Target } from "../core.interface";
|
|
1
|
+
import { Target, TargetPayload } from "../core.interface";
|
|
2
2
|
|
|
3
3
|
export enum CategoryList {
|
|
4
4
|
LIST = "list",
|
|
@@ -19,6 +19,6 @@ export interface ListDetails {
|
|
|
19
19
|
preview: string;
|
|
20
20
|
/** How to render the List */
|
|
21
21
|
loaderKey: string;
|
|
22
|
-
items: Array<
|
|
22
|
+
items: Array<TargetPayload<Target>>
|
|
23
23
|
};
|
|
24
24
|
|