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.
Files changed (76) hide show
  1. package/dist/auth/auth.api.js +6 -6
  2. package/dist/auth/auth.api.js.map +1 -1
  3. package/dist/core.api.d.ts +46 -6
  4. package/dist/core.api.d.ts.map +1 -1
  5. package/dist/core.api.js +105 -97
  6. package/dist/core.api.js.map +1 -1
  7. package/dist/core.interface.d.ts +2 -1
  8. package/dist/core.interface.d.ts.map +1 -1
  9. package/dist/core.interface.js +1 -1
  10. package/dist/core.interface.js.map +1 -1
  11. package/dist/core.utils.d.ts +0 -2
  12. package/dist/core.utils.d.ts.map +1 -1
  13. package/dist/core.utils.js +0 -5
  14. package/dist/core.utils.js.map +1 -1
  15. package/dist/file-list/file-list.api.js +1 -1
  16. package/dist/file-list/file-list.api.js.map +1 -1
  17. package/dist/index.d.ts +3 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +3 -0
  20. package/dist/index.js.map +1 -1
  21. package/dist/list/list.interface.d.ts +2 -2
  22. package/dist/list/list.interface.d.ts.map +1 -1
  23. package/dist/node/node.api.d.ts +32 -0
  24. package/dist/node/node.api.d.ts.map +1 -0
  25. package/dist/node/node.api.js +137 -0
  26. package/dist/node/node.api.js.map +1 -0
  27. package/dist/node/node.interface.d.ts +28 -0
  28. package/dist/node/node.interface.d.ts.map +1 -0
  29. package/dist/node/node.interface.js +17 -0
  30. package/dist/node/node.interface.js.map +1 -0
  31. package/dist/question-list/question-list.api.js +1 -1
  32. package/dist/question-list/question-list.api.js.map +1 -1
  33. package/dist/repo/repo.interface.d.ts +20 -0
  34. package/dist/repo/repo.interface.d.ts.map +1 -0
  35. package/dist/repo/repo.interface.js +8 -0
  36. package/dist/repo/repo.interface.js.map +1 -0
  37. package/dist/review/review.api.js +5 -5
  38. package/dist/review/review.api.js.map +1 -1
  39. package/dist/task/task-node-manager.d.ts +1 -0
  40. package/dist/task/task-node-manager.d.ts.map +1 -0
  41. package/dist/task/task-node-manager.js +2 -0
  42. package/dist/task/task-node-manager.js.map +1 -0
  43. package/dist/task/task.api.d.ts +1 -0
  44. package/dist/task/task.api.d.ts.map +1 -0
  45. package/dist/task/task.api.js +2 -0
  46. package/dist/task/task.api.js.map +1 -0
  47. package/dist/task/task.interface.d.ts +49 -0
  48. package/dist/task/task.interface.d.ts.map +1 -0
  49. package/dist/task/task.interface.js +26 -0
  50. package/dist/task/task.interface.js.map +1 -0
  51. package/dist/task/task.utils.d.ts +1 -0
  52. package/dist/task/task.utils.d.ts.map +1 -0
  53. package/dist/task/task.utils.js +2 -0
  54. package/dist/task/task.utils.js.map +1 -0
  55. package/dist/word/word.api.d.ts +5 -5
  56. package/dist/word/word.api.d.ts.map +1 -1
  57. package/dist/word/word.api.js +1 -1
  58. package/dist/word/word.api.js.map +1 -1
  59. package/package.json +1 -1
  60. package/src/auth/auth.api.ts +6 -6
  61. package/src/core.api.ts +194 -117
  62. package/src/core.interface.ts +3 -1
  63. package/src/core.utils.ts +0 -6
  64. package/src/file-list/file-list.api.ts +1 -1
  65. package/src/index.ts +6 -0
  66. package/src/list/list.interface.ts +2 -2
  67. package/src/node/node.api.ts +161 -0
  68. package/src/node/node.interface.ts +32 -0
  69. package/src/question-list/question-list.api.ts +1 -1
  70. package/src/repo/repo.interface.ts +23 -0
  71. package/src/review/review.api.ts +5 -5
  72. package/src/task/task-node-manager.ts +0 -0
  73. package/src/task/task.api.ts +0 -0
  74. package/src/task/task.interface.ts +60 -0
  75. package/src/task/task.utils.ts +0 -0
  76. package/src/word/word.api.ts +6 -6
@@ -1,5 +1,5 @@
1
1
  import { supabase } from "..";
2
- import { createResponse } from "../core.interface";
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 createResponse.success(data);
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 createResponse.success(data);
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 createResponse.success();
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 createResponse.success<User | null>(user as User | null);
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 createResponse.success();
84
+ return generateResponse.success();
85
85
  };
package/src/core.api.ts CHANGED
@@ -1,15 +1,39 @@
1
- import { pick } from "lodash-es";
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
- ? filterList.reduce((query, filter) => {
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 createResponse.success<Target>(data as Target);
57
+ return generateResponse.success<Target>(data as Target);
41
58
  };
42
59
 
43
60
  export const getPossibleTarget = async ({ filterList }: { filterList: QueryFilter[] }) => {
44
- const query = filterList.reduce((query, filter) => {
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 createResponse.success<Target | null>(data);
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: <explanation>
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
- filterList.reduce((query, filter) => {
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 createResponse.success<Target[]>(data);
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: <explanation>
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
- filterList.reduce(
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 }) // head: true means no row payload
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 createResponse.success<number>(count ?? 0);
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 = filterList.reduce((query, filter) => {
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 createResponse.success();
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.addValidator((val) => {
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 createResponse.success<Target>(data as Target);
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 createResponse.success<Target>(data as Target);
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
- id: string;
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
- details: updatedDetails,
259
- }
269
+ details: updatedDetails,
270
+ }
260
271
  : {
261
- details: updatedDetails,
262
- extra: updateExtraFn(currentDetails),
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 supabase.client.from("target").update(updated).eq("id", id).select().single();
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) => Omit<T, "id" | "created_at">;
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
- // (Optional)Step 1: validate payload
285
- const validPayload = validator != null ? new validator().validate(payload) : payload;
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
- // Step 3: create target
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
- return createResponse.success<T>(data);
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) {
@@ -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 createResponse = {
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;
@@ -9,7 +9,7 @@ export class PostCreateFileListValidator extends BaseValidator<PostCreateFileLis
9
9
  constructor() {
10
10
  super();
11
11
  // Add custom
12
- this.addValidator((val) => {
12
+ this.addCustomValidator((val) => {
13
13
  return true;
14
14
  });
15
15
  }
package/src/index.ts CHANGED
@@ -56,3 +56,9 @@ export * from "./link/link.api";
56
56
 
57
57
  export * from "./list/list.interface";
58
58
  export * from "./list/list.api";
59
+
60
+ export * from "./word/word.interface";
61
+
62
+ export * from "./task/task.interface";
63
+
64
+ export * from "./repo/repo.interface";
@@ -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<Omit<Target, 'id' | 'created_at'>>
22
+ items: Array<TargetPayload<Target>>
23
23
  };
24
24