target-supabase-sdk 0.1.7 → 0.1.9
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/README.md +8 -6
- package/dist/auth/auth.api.d.ts.map +1 -1
- package/dist/auth/auth.api.js +8 -7
- package/dist/auth/auth.api.js.map +1 -1
- package/dist/command/command.api.d.ts +43 -0
- package/dist/command/command.api.d.ts.map +1 -0
- package/dist/command/command.api.js +58 -0
- package/dist/command/command.api.js.map +1 -0
- package/dist/command/command.interface.d.ts +14 -0
- package/dist/command/command.interface.d.ts.map +1 -0
- package/dist/command/command.interface.js +9 -0
- package/dist/command/command.interface.js.map +1 -0
- package/dist/core.api.d.ts +116 -12
- package/dist/core.api.d.ts.map +1 -1
- package/dist/core.api.js +289 -103
- 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/file-manager.d.ts +1 -1
- package/dist/file/file-manager.d.ts.map +1 -1
- package/dist/file/file-manager.js +1 -1
- package/dist/file/file-manager.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 +12 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -1
- 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-manager.d.ts +48 -0
- package/dist/node/node-manager.d.ts.map +1 -0
- package/dist/node/node-manager.js +619 -0
- package/dist/node/node-manager.js.map +1 -0
- package/dist/node/node-task.preset.d.ts +1 -0
- package/dist/node/node-task.preset.d.ts.map +1 -0
- package/dist/node/node-task.preset.js +14 -0
- package/dist/node/node-task.preset.js.map +1 -0
- package/dist/node/node.api.d.ts +68 -0
- package/dist/node/node.api.d.ts.map +1 -0
- package/dist/node/node.api.js +100 -0
- package/dist/node/node.api.js.map +1 -0
- package/dist/node/node.interface.d.ts +35 -0
- package/dist/node/node.interface.d.ts.map +1 -0
- package/dist/node/node.interface.js +19 -0
- package/dist/node/node.interface.js.map +1 -0
- package/dist/node/node.utils.d.ts +5 -0
- package/dist/node/node.utils.d.ts.map +1 -0
- package/dist/node/node.utils.js +36 -0
- package/dist/node/node.utils.js.map +1 -0
- package/dist/parcel/parcel-manager.d.ts +1 -1
- package/dist/parcel/parcel-manager.d.ts.map +1 -1
- package/dist/parcel/parcel-manager.js +1 -1
- package/dist/parcel/parcel-manager.js.map +1 -1
- 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-context.utils.d.ts +24 -0
- package/dist/repo/repo-context.utils.d.ts.map +1 -0
- package/dist/repo/repo-context.utils.js +81 -0
- package/dist/repo/repo-context.utils.js.map +1 -0
- package/dist/repo/repo-manager.d.ts +31 -0
- package/dist/repo/repo-manager.d.ts.map +1 -0
- package/dist/repo/repo-manager.js +156 -0
- package/dist/repo/repo-manager.js.map +1 -0
- package/dist/repo/repo.api.d.ts +16 -0
- package/dist/repo/repo.api.d.ts.map +1 -0
- package/dist/repo/repo.api.js +40 -0
- package/dist/repo/repo.api.js.map +1 -0
- package/dist/repo/repo.interface.d.ts +27 -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/repo/repo.script-loader.d.ts +19 -0
- package/dist/repo/repo.script-loader.d.ts.map +1 -0
- package/dist/repo/repo.script-loader.js +108 -0
- package/dist/repo/repo.script-loader.js.map +1 -0
- package/dist/review/review.api.js +5 -5
- package/dist/review/review.api.js.map +1 -1
- package/dist/shared/log/create-api-logger.d.ts +17 -0
- package/dist/shared/log/create-api-logger.d.ts.map +1 -0
- package/dist/shared/log/create-api-logger.js +15 -0
- package/dist/shared/log/create-api-logger.js.map +1 -0
- package/dist/shared/log/log-manager.d.ts +114 -0
- package/dist/shared/log/log-manager.d.ts.map +1 -0
- package/dist/shared/log/log-manager.js +218 -0
- package/dist/shared/log/log-manager.js.map +1 -0
- package/dist/shared/utils/config-path.utils.d.ts +38 -0
- package/dist/shared/utils/config-path.utils.d.ts.map +1 -0
- package/dist/shared/utils/config-path.utils.js +85 -0
- package/dist/shared/utils/config-path.utils.js.map +1 -0
- package/dist/shared/utils/error.utils.d.ts +11 -0
- package/dist/shared/utils/error.utils.d.ts.map +1 -0
- package/dist/shared/utils/error.utils.js +15 -0
- package/dist/shared/utils/error.utils.js.map +1 -0
- package/dist/shared/utils/id.utils.d.ts +3 -0
- package/dist/shared/utils/id.utils.d.ts.map +1 -0
- package/dist/shared/utils/id.utils.js +5 -0
- package/dist/shared/utils/id.utils.js.map +1 -0
- package/dist/supabase.js +1 -1
- package/dist/supabase.js.map +1 -1
- package/dist/task/local-task-registry.d.ts +27 -0
- package/dist/task/local-task-registry.d.ts.map +1 -0
- package/dist/task/local-task-registry.js +153 -0
- package/dist/task/local-task-registry.js.map +1 -0
- package/dist/task/task-manager.d.ts +52 -0
- package/dist/task/task-manager.d.ts.map +1 -0
- package/dist/task/task-manager.js +217 -0
- package/dist/task/task-manager.js.map +1 -0
- package/dist/task/task-repo-context.d.ts +95 -0
- package/dist/task/task-repo-context.d.ts.map +1 -0
- package/dist/task/task-repo-context.js +12 -0
- package/dist/task/task-repo-context.js.map +1 -0
- package/dist/task/task.api.d.ts +241 -0
- package/dist/task/task.api.d.ts.map +1 -0
- package/dist/task/task.api.js +351 -0
- package/dist/task/task.api.js.map +1 -0
- package/dist/task/task.interface.d.ts +56 -0
- package/dist/task/task.interface.d.ts.map +1 -0
- package/dist/task/task.interface.js +36 -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 +10 -3
- package/src/auth/auth.api.ts +9 -7
- package/src/command/command.api.ts +88 -0
- package/src/command/command.interface.ts +17 -0
- package/src/core.api.ts +489 -133
- package/src/core.interface.ts +3 -1
- package/src/core.utils.ts +0 -6
- package/src/file/file-manager.ts +1 -1
- package/src/file-list/file-list.api.ts +1 -1
- package/src/index.ts +22 -1
- package/src/list/list.interface.ts +2 -2
- package/src/node/node-manager.ts +673 -0
- package/src/node/node-task.preset.ts +13 -0
- package/src/node/node.api.ts +139 -0
- package/src/node/node.interface.ts +40 -0
- package/src/node/node.utils.ts +36 -0
- package/src/parcel/parcel-manager.ts +1 -1
- package/src/question-list/question-list.api.ts +1 -1
- package/src/repo/repo-context.utils.ts +111 -0
- package/src/repo/repo-manager.ts +217 -0
- package/src/repo/repo.api.ts +50 -0
- package/src/repo/repo.interface.ts +31 -0
- package/src/repo/repo.script-loader.ts +140 -0
- package/src/review/review.api.ts +5 -5
- package/src/shared/log/create-api-logger.ts +25 -0
- package/src/shared/log/log-manager.ts +340 -0
- package/src/shared/utils/config-path.utils.ts +104 -0
- package/src/shared/utils/error.utils.ts +15 -0
- package/src/shared/utils/id.utils.ts +4 -0
- package/src/supabase.ts +1 -1
- package/src/task/local-task-registry.ts +195 -0
- package/src/task/task-manager.ts +312 -0
- package/src/task/task-repo-context.ts +103 -0
- package/src/task/task.api.ts +429 -0
- package/src/task/task.interface.ts +68 -0
- package/src/task/task.utils.ts +0 -0
- package/src/word/word.api.ts +6 -6
package/src/core.api.ts
CHANGED
|
@@ -1,15 +1,124 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
|
|
1
|
+
import { generateResponse, Target, TargetPayload } from "./core.interface";
|
|
2
|
+
import { SupabaseInitializer } from "./supabase";
|
|
3
|
+
|
|
4
|
+
const supabase = SupabaseInitializer.getInstance();
|
|
4
5
|
import { BaseValidator, handleSupabaseError } from "./core.utils";
|
|
5
6
|
import { PostgrestFilterBuilder } from "@supabase/postgrest-js";
|
|
7
|
+
import type { ZodType } from "zod";
|
|
8
|
+
import { ZodError } from "zod";
|
|
6
9
|
|
|
7
10
|
export interface QueryFilter {
|
|
8
11
|
field: string;
|
|
9
|
-
operator: "eq";
|
|
12
|
+
operator: "eq" | "neq" | "in";
|
|
10
13
|
value: unknown;
|
|
11
14
|
}
|
|
12
15
|
|
|
16
|
+
export const MAX_TARGET_LIST_PAGE_SIZE = 100;
|
|
17
|
+
|
|
18
|
+
/** Alias for {@link pollTargetList} batch size cap. */
|
|
19
|
+
export const MAX_POLL_TARGET_LIST_SIZE = MAX_TARGET_LIST_PAGE_SIZE;
|
|
20
|
+
|
|
21
|
+
const TARGET_LIST_ORDER_FIELDS = new Set<string>(["created_at", "name", "value", "category"]);
|
|
22
|
+
|
|
23
|
+
// biome-ignore lint/suspicious/noExplicitAny: PostgrestFilterBuilder is generic over schema
|
|
24
|
+
export type TargetFilterBuilder = PostgrestFilterBuilder<any, any, any[], "target", unknown>;
|
|
25
|
+
|
|
26
|
+
function validateTargetListPagination(pageNum: number, pageSize: number): void {
|
|
27
|
+
if (!Number.isInteger(pageNum) || pageNum < 0) {
|
|
28
|
+
throw new Error(`[getTargetList] pageNum must be a non-negative integer, got ${pageNum}`);
|
|
29
|
+
}
|
|
30
|
+
if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > MAX_TARGET_LIST_PAGE_SIZE) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
`[getTargetList] pageSize must be an integer between 1 and ${MAX_TARGET_LIST_PAGE_SIZE}, got ${pageSize}`
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function resolveTargetListPagination(params: {
|
|
38
|
+
pageNum?: number;
|
|
39
|
+
pageSize?: number;
|
|
40
|
+
limit?: number;
|
|
41
|
+
}): {
|
|
42
|
+
pageNum: number;
|
|
43
|
+
pageSize: number;
|
|
44
|
+
} {
|
|
45
|
+
if (params.limit != null) {
|
|
46
|
+
validateTargetListPagination(0, params.limit);
|
|
47
|
+
return { pageNum: 0, pageSize: params.limit };
|
|
48
|
+
}
|
|
49
|
+
if (params.pageNum === undefined || params.pageSize === undefined) {
|
|
50
|
+
throw new Error("[getTargetList] Provide pageNum and pageSize, or limit.");
|
|
51
|
+
}
|
|
52
|
+
validateTargetListPagination(params.pageNum, params.pageSize);
|
|
53
|
+
return { pageNum: params.pageNum, pageSize: params.pageSize };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function validateTargetListOrderBy(orderBy: QueryOrderBy): void {
|
|
57
|
+
if (!TARGET_LIST_ORDER_FIELDS.has(orderBy.field)) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
`[getTargetList] Unsupported orderBy.field "${orderBy.field}"; allowed: ${[...TARGET_LIST_ORDER_FIELDS].join(", ")}`
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 构建带 category 作用域的 target 查询(供 getTargetList / getTargetTotalCount / pollTargetList 共用)。
|
|
66
|
+
*
|
|
67
|
+
* 必要性:
|
|
68
|
+
* 1. category 是 target 表的分区维度,所有列表/计数/出队都必须限定 category,避免跨类型扫表。
|
|
69
|
+
* 2. 传入 filterBuilder 时仍强制 `.eq("category", category)`,防止自定义 builder 漏加 category 导致误查或安全问题。
|
|
70
|
+
* 3. 统一 select 普通列表与 count head 两种查询形态,再叠加 filterList,避免三处复制分支逻辑。
|
|
71
|
+
*/
|
|
72
|
+
function buildCategoryScopedQuery({
|
|
73
|
+
category,
|
|
74
|
+
filterList,
|
|
75
|
+
filterBuilder,
|
|
76
|
+
select,
|
|
77
|
+
selectOptions,
|
|
78
|
+
}: {
|
|
79
|
+
category: Target["category"];
|
|
80
|
+
filterList: QueryFilter[];
|
|
81
|
+
filterBuilder?: TargetFilterBuilder;
|
|
82
|
+
select: string;
|
|
83
|
+
selectOptions?: { count: "exact"; head: true };
|
|
84
|
+
}) {
|
|
85
|
+
const base =
|
|
86
|
+
filterBuilder != null
|
|
87
|
+
? filterBuilder.eq("category", category)
|
|
88
|
+
: selectOptions != null
|
|
89
|
+
? supabase.client.from("target").select(select, selectOptions).eq("category", category)
|
|
90
|
+
: supabase.client.from("target").select(select).eq("category", category);
|
|
91
|
+
|
|
92
|
+
return applyQueryFilters(base, filterList);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// biome-ignore lint/suspicious/noExplicitAny: PostgrestFilterBuilder is generic over schema
|
|
96
|
+
function applyQueryFilter<Q extends PostgrestFilterBuilder<any, any, any, any, any>>(
|
|
97
|
+
query: Q,
|
|
98
|
+
filter: QueryFilter
|
|
99
|
+
): Q {
|
|
100
|
+
switch (filter.operator) {
|
|
101
|
+
case "eq":
|
|
102
|
+
return query.eq(filter.field, filter.value) as Q;
|
|
103
|
+
case "neq":
|
|
104
|
+
return query.neq(filter.field, filter.value) as Q;
|
|
105
|
+
case "in":
|
|
106
|
+
return query.in(filter.field, filter.value as unknown[]) as Q;
|
|
107
|
+
default: {
|
|
108
|
+
const unsupportedOperator: never = filter.operator;
|
|
109
|
+
throw new Error(`[applyQueryFilter] Unsupported operator: ${unsupportedOperator}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// biome-ignore lint/suspicious/noExplicitAny: PostgrestFilterBuilder is generic over schema
|
|
115
|
+
function applyQueryFilters<Q extends PostgrestFilterBuilder<any, any, any, any, any>>(
|
|
116
|
+
query: Q,
|
|
117
|
+
filterList: QueryFilter[]
|
|
118
|
+
): Q {
|
|
119
|
+
return filterList.reduce((q, filter) => applyQueryFilter(q, filter), query);
|
|
120
|
+
}
|
|
121
|
+
|
|
13
122
|
export interface QueryOrderBy {
|
|
14
123
|
field: string;
|
|
15
124
|
ascending: boolean;
|
|
@@ -23,125 +132,253 @@ export interface BaseQueryParams {
|
|
|
23
132
|
export const getTarget = async ({ id, filterList }: { id: string; filterList?: QueryFilter[] }) => {
|
|
24
133
|
const query =
|
|
25
134
|
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))
|
|
135
|
+
? applyQueryFilters(supabase.client.from("target").select().eq("id", id), filterList)
|
|
34
136
|
: supabase.client.from("target").select().eq("id", id);
|
|
35
137
|
|
|
36
138
|
const { data, error } = await query.single();
|
|
37
139
|
if (error) {
|
|
38
140
|
handleSupabaseError("getTarget", error, "Failed to fetch target.");
|
|
39
141
|
}
|
|
40
|
-
return
|
|
142
|
+
return generateResponse.success<Target>(data as Target);
|
|
41
143
|
};
|
|
42
144
|
|
|
43
145
|
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());
|
|
146
|
+
const query = applyQueryFilters(supabase.client.from("target").select(), filterList);
|
|
52
147
|
|
|
53
148
|
const { data, error } = await query.maybeSingle();
|
|
54
149
|
if (error) {
|
|
55
150
|
handleSupabaseError("getPossibleTarget", error, "Failed to fetch target.");
|
|
56
151
|
}
|
|
57
|
-
return
|
|
152
|
+
return generateResponse.success<Target | null>(data);
|
|
58
153
|
};
|
|
59
154
|
|
|
60
155
|
export interface GetTargetListParams extends BaseQueryParams {
|
|
61
|
-
pageNum: number;
|
|
62
|
-
pageSize: number;
|
|
63
156
|
category: Target["category"];
|
|
157
|
+
/** 0-based page index. Required with pageSize unless `limit` is set. */
|
|
158
|
+
pageNum?: number;
|
|
159
|
+
pageSize?: number;
|
|
160
|
+
/** Shorthand for `{ pageNum: 0, pageSize: limit }`. Max {@link MAX_TARGET_LIST_PAGE_SIZE}. */
|
|
161
|
+
limit?: number;
|
|
162
|
+
/** PostgREST select clause; defaults to `"*"`. */
|
|
163
|
+
selectFields?: string;
|
|
164
|
+
/** Custom query builder; `category` and `filterList` are still applied. */
|
|
165
|
+
filterBuilder?: TargetFilterBuilder;
|
|
64
166
|
}
|
|
65
167
|
|
|
66
|
-
|
|
168
|
+
/**
|
|
169
|
+
* Paginated target list by category. Failures throw via `handleSupabaseError` (no `{ error }` envelope).
|
|
170
|
+
*/
|
|
171
|
+
export const getTargetList = async <T extends Target = Target>({
|
|
67
172
|
pageNum,
|
|
68
173
|
pageSize,
|
|
174
|
+
limit,
|
|
69
175
|
category,
|
|
70
176
|
filterList = [],
|
|
71
177
|
orderBy = { field: "created_at", ascending: false },
|
|
178
|
+
selectFields = "*",
|
|
72
179
|
filterBuilder,
|
|
73
|
-
}: GetTargetListParams
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
180
|
+
}: GetTargetListParams) => {
|
|
181
|
+
const { pageNum: resolvedPageNum, pageSize: resolvedPageSize } = resolveTargetListPagination({
|
|
182
|
+
pageNum,
|
|
183
|
+
pageSize,
|
|
184
|
+
limit,
|
|
185
|
+
});
|
|
186
|
+
validateTargetListOrderBy(orderBy);
|
|
187
|
+
|
|
188
|
+
const query = buildCategoryScopedQuery({
|
|
189
|
+
category,
|
|
190
|
+
filterList,
|
|
191
|
+
filterBuilder,
|
|
192
|
+
select: selectFields,
|
|
193
|
+
});
|
|
87
194
|
|
|
88
195
|
const { data, error } = await query
|
|
89
196
|
.order(orderBy.field, { ascending: orderBy.ascending })
|
|
90
|
-
.range(
|
|
197
|
+
.range(resolvedPageNum * resolvedPageSize, (resolvedPageNum + 1) * resolvedPageSize - 1);
|
|
91
198
|
|
|
92
199
|
if (error) {
|
|
93
200
|
handleSupabaseError("getTargetList", error, "Failed to fetch target list.");
|
|
94
201
|
}
|
|
95
|
-
return
|
|
202
|
+
return generateResponse.success<T[]>((data ?? []) as T[]);
|
|
96
203
|
};
|
|
97
204
|
|
|
98
|
-
export interface
|
|
205
|
+
export interface ScanTargetListParams {
|
|
99
206
|
category: Target["category"];
|
|
100
|
-
|
|
101
|
-
|
|
207
|
+
filterList?: QueryFilter[];
|
|
208
|
+
orderBy?: QueryOrderBy;
|
|
209
|
+
selectFields?: string;
|
|
210
|
+
filterBuilder?: TargetFilterBuilder;
|
|
211
|
+
/** Stop after this many rows (optional guard against runaway scans). */
|
|
212
|
+
maxRows?: number;
|
|
102
213
|
}
|
|
103
214
|
|
|
215
|
+
/**
|
|
216
|
+
* Scan all rows matching `category` + `filterList` by paging until exhausted.
|
|
217
|
+
*
|
|
218
|
+
* Use for worker bootstrap / sync jobs that need the full matching set.
|
|
219
|
+
* For UI pagination, use {@link getTargetList} (single page, explicit pageNum).
|
|
220
|
+
*
|
|
221
|
+
* Internal batch size is fixed at {@link MAX_TARGET_LIST_PAGE_SIZE} (PostgREST max-rows).
|
|
222
|
+
* Callers receive the full merged result; they do not pass a per-request page size.
|
|
223
|
+
*
|
|
224
|
+
* Uses offset pagination (`.range`). Stable enough for small catalogs (e.g. Repo registry);
|
|
225
|
+
* for large tables under concurrent writes, consider a DB RPC or keyset pagination later.
|
|
226
|
+
*/
|
|
227
|
+
export const scanTargetList = async <T extends Target = Target>({
|
|
228
|
+
category,
|
|
229
|
+
filterList = [],
|
|
230
|
+
orderBy = { field: "created_at", ascending: false },
|
|
231
|
+
selectFields = "*",
|
|
232
|
+
filterBuilder,
|
|
233
|
+
maxRows,
|
|
234
|
+
}: ScanTargetListParams) => {
|
|
235
|
+
const batchSize = MAX_TARGET_LIST_PAGE_SIZE;
|
|
236
|
+
validateTargetListOrderBy(orderBy);
|
|
237
|
+
if (maxRows != null && (!Number.isInteger(maxRows) || maxRows < 1)) {
|
|
238
|
+
throw new Error(`[scanTargetList] maxRows must be a positive integer, got ${maxRows}`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const rows: T[] = [];
|
|
242
|
+
let pageNum = 0;
|
|
243
|
+
|
|
244
|
+
while (true) {
|
|
245
|
+
const query = buildCategoryScopedQuery({
|
|
246
|
+
category,
|
|
247
|
+
filterList,
|
|
248
|
+
filterBuilder,
|
|
249
|
+
select: selectFields,
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
const { data, error } = await query
|
|
253
|
+
.order(orderBy.field, { ascending: orderBy.ascending })
|
|
254
|
+
.range(pageNum * batchSize, (pageNum + 1) * batchSize - 1);
|
|
255
|
+
|
|
256
|
+
if (error) {
|
|
257
|
+
handleSupabaseError("scanTargetList", error, "Failed to scan target list.");
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const page = (data ?? []) as T[];
|
|
261
|
+
if (page.length === 0) {
|
|
262
|
+
break;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
rows.push(...page);
|
|
266
|
+
|
|
267
|
+
if (maxRows != null && rows.length >= maxRows) {
|
|
268
|
+
rows.length = maxRows;
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (page.length < batchSize) {
|
|
273
|
+
break;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
pageNum += 1;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return generateResponse.success(rows);
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
export interface GetTargetTotalCountParams extends BaseQueryParams {
|
|
283
|
+
category: Target["category"];
|
|
284
|
+
/** Custom query builder; `category` and `filterList` are still applied. */
|
|
285
|
+
filterBuilder?: TargetFilterBuilder;
|
|
286
|
+
}
|
|
104
287
|
export const getTargetTotalCount = async ({
|
|
105
288
|
category,
|
|
106
289
|
filterList = [],
|
|
107
290
|
filterBuilder,
|
|
108
291
|
}: GetTargetTotalCountParams) => {
|
|
109
|
-
const query =
|
|
110
|
-
|
|
111
|
-
filterList
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
default:
|
|
117
|
-
return query;
|
|
118
|
-
}
|
|
119
|
-
},
|
|
120
|
-
supabase.client
|
|
121
|
-
.from("target")
|
|
122
|
-
.select("id", { count: "exact", head: true }) // head: true means no row payload
|
|
123
|
-
.eq("category", category)
|
|
124
|
-
);
|
|
292
|
+
const query = buildCategoryScopedQuery({
|
|
293
|
+
category,
|
|
294
|
+
filterList,
|
|
295
|
+
filterBuilder,
|
|
296
|
+
select: "id",
|
|
297
|
+
selectOptions: { count: "exact", head: true },
|
|
298
|
+
});
|
|
125
299
|
|
|
126
300
|
const { count, error } = await query;
|
|
127
301
|
|
|
128
302
|
if (error) {
|
|
129
303
|
handleSupabaseError("getTargetTotalCount", error, "Failed to fetch target count.");
|
|
130
304
|
}
|
|
131
|
-
return
|
|
305
|
+
return generateResponse.success<number>(count ?? 0);
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
function validatePollTargetListSize(size: number): void {
|
|
309
|
+
if (!Number.isInteger(size) || size < 1 || size > MAX_POLL_TARGET_LIST_SIZE) {
|
|
310
|
+
throw new Error(
|
|
311
|
+
`[pollTargetList] size must be an integer between 1 and ${MAX_POLL_TARGET_LIST_SIZE}, got ${size}`
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export interface PollTargetListParams {
|
|
317
|
+
category: Target["category"];
|
|
318
|
+
/** Max rows to read and attempt dequeue (not offset pagination). */
|
|
319
|
+
size: number;
|
|
320
|
+
filterList?: QueryFilter[];
|
|
321
|
+
/** Order by `created_at`. Default `true` (oldest first). */
|
|
322
|
+
ascending?: boolean;
|
|
323
|
+
selectFields?: string;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Queue poll: SELECT up to `size` rows matching `category` + `filterList`, ordered by
|
|
328
|
+
* `created_at`, then DELETE each by `id`. Returns only dequeued rows (at-most-once).
|
|
329
|
+
*
|
|
330
|
+
* Delete failures (concurrent consumer) are skipped silently.
|
|
331
|
+
* Failures on the initial SELECT throw via `handleSupabaseError`.
|
|
332
|
+
*
|
|
333
|
+
* TODO(concurrency): 当前为 SELECT → 逐条 DELETE,非 DB 原子出队,多 consumer 并发时可能:
|
|
334
|
+
* - 重叠读取:两个 worker 同一批 SELECT 命中相同行,靠 DELETE 竞速,后者 skip(多数 at-most-once)
|
|
335
|
+
* - TOCTOU:SELECT 与 DELETE 之间无锁,极端情况下 DELETE 0 行仍不报错 → 可能重复计入 polled
|
|
336
|
+
* - 非 exactly-once:行已删但 worker 崩溃未处理 → 消息丢失
|
|
337
|
+
* 若同一 filter 多实例 poll(或多 worker 同 nodeId),需改为 DB 侧原子方案,例如:
|
|
338
|
+
* - RPC:`DELETE … WHERE id IN (SELECT … FOR UPDATE SKIP LOCKED) RETURNING *`
|
|
339
|
+
* - 或 claim 状态机:UPDATE … WHERE pending → DELETE/标记已消费(类似 patchClaimTask 乐观锁)
|
|
340
|
+
* - 短期加固:DELETE 后校验影响行数,0 行则不 push 到 polled
|
|
341
|
+
*/
|
|
342
|
+
export const pollTargetList = async <T extends Target = Target>({
|
|
343
|
+
category,
|
|
344
|
+
size,
|
|
345
|
+
filterList = [],
|
|
346
|
+
ascending = true,
|
|
347
|
+
selectFields = "*",
|
|
348
|
+
}: PollTargetListParams) => {
|
|
349
|
+
validatePollTargetListSize(size);
|
|
350
|
+
|
|
351
|
+
const query = buildCategoryScopedQuery({
|
|
352
|
+
category,
|
|
353
|
+
filterList,
|
|
354
|
+
select: selectFields,
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
const { data, error } = await query.order("created_at", { ascending }).limit(size);
|
|
358
|
+
|
|
359
|
+
if (error) {
|
|
360
|
+
handleSupabaseError("pollTargetList", error, "Failed to fetch targets for poll.");
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const candidates = (data ?? []) as T[];
|
|
364
|
+
const polled: T[] = [];
|
|
365
|
+
|
|
366
|
+
for (const row of candidates) {
|
|
367
|
+
try {
|
|
368
|
+
await deleteTarget({ id: row.id });
|
|
369
|
+
polled.push(row);
|
|
370
|
+
} catch {
|
|
371
|
+
// Concurrent dequeue — row already removed
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return generateResponse.success<T[]>(polled);
|
|
132
376
|
};
|
|
133
377
|
|
|
134
378
|
export const deleteTarget = async ({ id, filterList }: { id: string; filterList?: QueryFilter[] }) => {
|
|
135
379
|
// Check whether the target exists given filterList
|
|
136
380
|
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));
|
|
381
|
+
const query = applyQueryFilters(supabase.client.from("target").select().eq("id", id), filterList);
|
|
145
382
|
const { data: existingTarget, error: existingError } = await query.single();
|
|
146
383
|
if (existingError || existingTarget == null) {
|
|
147
384
|
const msg = `[deleteTarget] Cannot find the target ${id}`;
|
|
@@ -153,7 +390,7 @@ export const deleteTarget = async ({ id, filterList }: { id: string; filterList?
|
|
|
153
390
|
if (error) {
|
|
154
391
|
handleSupabaseError("deleteTarget", error, `Failed to delete target ${id}.`);
|
|
155
392
|
}
|
|
156
|
-
return
|
|
393
|
+
return generateResponse.success();
|
|
157
394
|
};
|
|
158
395
|
|
|
159
396
|
export interface PostTargetPayload {
|
|
@@ -172,7 +409,7 @@ class PostTargetPayloadValidator extends BaseValidator<PostTargetPayload> {
|
|
|
172
409
|
constructor() {
|
|
173
410
|
super();
|
|
174
411
|
// Add custom
|
|
175
|
-
this.
|
|
412
|
+
this.addCustomValidator((val) => {
|
|
176
413
|
return true;
|
|
177
414
|
});
|
|
178
415
|
}
|
|
@@ -190,7 +427,7 @@ export const postTarget = async (payload: PostTargetPayload) => {
|
|
|
190
427
|
if (error) {
|
|
191
428
|
handleSupabaseError("postTarget", error, "Failed to create target.");
|
|
192
429
|
}
|
|
193
|
-
return
|
|
430
|
+
return generateResponse.success<Target>(data as Target);
|
|
194
431
|
};
|
|
195
432
|
|
|
196
433
|
export interface PatchTargetPayload extends PostTargetPayload {
|
|
@@ -218,25 +455,55 @@ export const patchTarget = async ({ id, ...restPayload }: PatchTargetPayload) =>
|
|
|
218
455
|
if (error) {
|
|
219
456
|
handleSupabaseError("patchTarget", error, "Failed to update target.");
|
|
220
457
|
}
|
|
221
|
-
return
|
|
458
|
+
return generateResponse.success<Target>(data as Target);
|
|
222
459
|
};
|
|
223
460
|
|
|
461
|
+
/**
|
|
462
|
+
* Read-modify-write `target.details` with optional DB-level optimistic locking on UPDATE.
|
|
463
|
+
*
|
|
464
|
+
* Do not use application-layer pre-update validators (e.g. checking state after SELECT then
|
|
465
|
+
* UPDATE with only `id`). That pattern is not atomic and loses under concurrency. Pass expected
|
|
466
|
+
* row conditions via `optimisticLockFilterList` so they are applied on the UPDATE statement.
|
|
467
|
+
*/
|
|
468
|
+
export interface UpdateTargetDetailsParams<D> {
|
|
469
|
+
/** Target row id. */
|
|
470
|
+
id: string;
|
|
471
|
+
/**
|
|
472
|
+
* Computes new details from the row fetched immediately before UPDATE.
|
|
473
|
+
* Runs after SELECT; the write still relies on `optimisticLockFilterList` for atomic guards.
|
|
474
|
+
*/
|
|
475
|
+
updateFn: (existing: D) => D;
|
|
476
|
+
/**
|
|
477
|
+
* Optional `target.extra` derived from existing details (written together with details).
|
|
478
|
+
*/
|
|
479
|
+
updateExtraFn?: (existing: D) => string;
|
|
480
|
+
/**
|
|
481
|
+
* Optimistic lock conditions applied on UPDATE (not on the prior SELECT).
|
|
482
|
+
*
|
|
483
|
+
* Replaces the removed `beforeUpdateValidator`: validators ran in app code between read and
|
|
484
|
+
* write and could not prevent concurrent overwrites. These filters become part of the UPDATE
|
|
485
|
+
* WHERE clause (via `applyQueryFilters`), e.g. `{ field: "details->>status", operator: "eq", value: "TODO" }`.
|
|
486
|
+
*
|
|
487
|
+
* If no row matches after UPDATE, throws "Optimistic lock failed". Empty array = update by id only.
|
|
488
|
+
*/
|
|
489
|
+
optimisticLockFilterList?: QueryFilter[];
|
|
490
|
+
}
|
|
491
|
+
|
|
224
492
|
export const updateTargetDetails = async <T, D>({
|
|
225
493
|
id,
|
|
226
494
|
updateFn,
|
|
227
|
-
beforeUpdateValidator = () => true,
|
|
228
495
|
updateExtraFn,
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
updateFn: (existing: D) => D;
|
|
232
|
-
beforeUpdateValidator?: (existing: D) => true | string;
|
|
233
|
-
updateExtraFn?: (existing: D) => string;
|
|
234
|
-
}) => {
|
|
496
|
+
optimisticLockFilterList = [],
|
|
497
|
+
}: UpdateTargetDetailsParams<D>) => {
|
|
235
498
|
const { data: currentData, error: fetchError } = await supabase.client
|
|
236
499
|
.from("target")
|
|
237
500
|
.select("details")
|
|
238
501
|
.eq("id", id)
|
|
239
502
|
.single();
|
|
503
|
+
|
|
504
|
+
if (fetchError) {
|
|
505
|
+
handleSupabaseError("updateTargetDetails", fetchError, "Failed to fetch target.");
|
|
506
|
+
}
|
|
240
507
|
if (!currentData) {
|
|
241
508
|
const msg = "[updateTargetDetails] Target NOT exists: " + id;
|
|
242
509
|
console.error(msg);
|
|
@@ -244,30 +511,85 @@ export const updateTargetDetails = async <T, D>({
|
|
|
244
511
|
}
|
|
245
512
|
|
|
246
513
|
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
514
|
const updatedDetails: D = updateFn(currentDetails);
|
|
255
515
|
const updated =
|
|
256
516
|
updateExtraFn == null
|
|
257
517
|
? {
|
|
258
|
-
|
|
259
|
-
|
|
518
|
+
details: updatedDetails,
|
|
519
|
+
}
|
|
260
520
|
: {
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
521
|
+
details: updatedDetails,
|
|
522
|
+
extra: updateExtraFn(currentDetails),
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
const updateQuery = applyQueryFilters(
|
|
526
|
+
supabase.client.from("target").update(updated).eq("id", id),
|
|
527
|
+
optimisticLockFilterList
|
|
528
|
+
);
|
|
529
|
+
|
|
530
|
+
const { data, error } = await updateQuery.select().maybeSingle();
|
|
264
531
|
|
|
265
|
-
|
|
532
|
+
if (error) {
|
|
533
|
+
handleSupabaseError("updateTargetDetails", error, "Failed to update target details.");
|
|
534
|
+
}
|
|
535
|
+
if (!data) {
|
|
536
|
+
const msg =
|
|
537
|
+
optimisticLockFilterList.length > 0
|
|
538
|
+
? OPTIMISTIC_LOCK_FAILED_MESSAGE
|
|
539
|
+
: "[updateTargetDetails] Target not found or was deleted.";
|
|
540
|
+
throw new Error(msg);
|
|
541
|
+
}
|
|
266
542
|
|
|
267
|
-
if (error) handleSupabaseError("updateTargetDetails", error, "Failed to update target details.");
|
|
268
543
|
return data as T;
|
|
269
544
|
};
|
|
270
545
|
|
|
546
|
+
export const OPTIMISTIC_LOCK_FAILED_MESSAGE =
|
|
547
|
+
"[updateTargetDetails] Optimistic lock failed: target no longer matches expected state.";
|
|
548
|
+
|
|
549
|
+
export const CREATE_TARGET_ALREADY_EXISTS_MESSAGE = "[createTarget] Target already exists";
|
|
550
|
+
|
|
551
|
+
const CREATE_TARGET_REDUNDANCY_MISMATCH_MESSAGE =
|
|
552
|
+
"[createTarget] Inserted row does not match checkRedundancyFilterList; verify createFn aligns with filters.";
|
|
553
|
+
|
|
554
|
+
/** Max rows to fetch during post-verify — only need to detect one other match. */
|
|
555
|
+
const REDUNDANCY_VERIFY_ROW_LIMIT = 2;
|
|
556
|
+
|
|
557
|
+
export function isOptimisticLockError(error: unknown): boolean {
|
|
558
|
+
return error instanceof Error && error.message === OPTIMISTIC_LOCK_FAILED_MESSAGE;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
export function isCreateTargetAlreadyExistsError(error: unknown): boolean {
|
|
562
|
+
return error instanceof Error && error.message === CREATE_TARGET_ALREADY_EXISTS_MESSAGE;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** Roll back an optimistic insert when redundancy filters match other rows. */
|
|
566
|
+
async function rollbackCreateTargetInsert(id: string) {
|
|
567
|
+
const { error } = await supabase.client.from("target").delete().eq("id", id);
|
|
568
|
+
if (error) {
|
|
569
|
+
handleSupabaseError("createTarget", error, "Failed to rollback conflicting insert.");
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/** Roll back the new row and signal a redundancy conflict to the caller. */
|
|
574
|
+
function throwCreateTargetAlreadyExists(checkRedundancyFilterList: QueryFilter[]): never {
|
|
575
|
+
console.error(CREATE_TARGET_ALREADY_EXISTS_MESSAGE, checkRedundancyFilterList);
|
|
576
|
+
throw new Error(CREATE_TARGET_ALREADY_EXISTS_MESSAGE);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
async function failCreateTargetRedundancy(
|
|
580
|
+
selfId: string,
|
|
581
|
+
checkRedundancyFilterList: QueryFilter[]
|
|
582
|
+
): Promise<never> {
|
|
583
|
+
await rollbackCreateTargetInsert(selfId);
|
|
584
|
+
return throwCreateTargetAlreadyExists(checkRedundancyFilterList);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Create a `target` row with optional payload validation and optimistic redundancy checks.
|
|
589
|
+
*
|
|
590
|
+
* When `checkRedundancyFilterList` is set, uses insert-first + post-verify (not SELECT-before-INSERT).
|
|
591
|
+
* See `.cursor/skills/create-target-redundancy/SKILL.md`.
|
|
592
|
+
*/
|
|
271
593
|
export const createTarget = async <T extends Target, P extends object>({
|
|
272
594
|
payload,
|
|
273
595
|
validator,
|
|
@@ -277,55 +599,59 @@ export const createTarget = async <T extends Target, P extends object>({
|
|
|
277
599
|
}: {
|
|
278
600
|
payload: P;
|
|
279
601
|
validator?: new () => BaseValidator<P>;
|
|
280
|
-
createFn: (validPayload: P) =>
|
|
602
|
+
createFn: (validPayload: P) => TargetPayload<T>;
|
|
603
|
+
/** Business-key filters; if another row matches after insert, conflict handling runs. */
|
|
281
604
|
checkRedundancyFilterList?: QueryFilter[];
|
|
605
|
+
/**
|
|
606
|
+
* @deprecated Not supported — ignored at runtime. Use domain patch APIs or future RPC upsert.
|
|
607
|
+
* Reserved for a later release; do not pass `true`.
|
|
608
|
+
*/
|
|
282
609
|
upsert?: boolean;
|
|
283
610
|
}) => {
|
|
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
|
-
}
|
|
611
|
+
if (upsert) {
|
|
612
|
+
console.warn("[createTarget] `upsert` is deprecated and has no effect.");
|
|
320
613
|
}
|
|
321
614
|
|
|
322
|
-
|
|
615
|
+
const validPayload = validator != null ? new validator().validate(payload) : payload;
|
|
323
616
|
const newTarget = createFn(validPayload);
|
|
617
|
+
|
|
618
|
+
const hasRedundancyCheck =
|
|
619
|
+
checkRedundancyFilterList != null && checkRedundancyFilterList.length > 0;
|
|
620
|
+
|
|
621
|
+
// 1. Optimistic insert (see create-target-redundancy skill)
|
|
324
622
|
const { data, error } = await supabase.client.from("target").insert([newTarget]).select().single();
|
|
325
623
|
if (error) {
|
|
326
624
|
handleSupabaseError("createTarget", error, "Failed to create target.");
|
|
327
625
|
}
|
|
328
|
-
|
|
626
|
+
|
|
627
|
+
if (!hasRedundancyCheck) {
|
|
628
|
+
return generateResponse.success<T>(data);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// 2. Post-verify: limit(2) — enough to detect one other row matching the business-key filters
|
|
632
|
+
const { data: matches, error: matchError } = await applyQueryFilters(
|
|
633
|
+
supabase.client.from("target").select("id"),
|
|
634
|
+
checkRedundancyFilterList
|
|
635
|
+
).limit(REDUNDANCY_VERIFY_ROW_LIMIT);
|
|
636
|
+
if (matchError) {
|
|
637
|
+
handleSupabaseError("createTarget", matchError, "Failed to verify target redundancy.");
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
const selfId = data.id as string;
|
|
641
|
+
const rows = matches ?? [];
|
|
642
|
+
const selfMatchesFilters = rows.some((row) => row.id === selfId);
|
|
643
|
+
if (!selfMatchesFilters) {
|
|
644
|
+
console.warn(CREATE_TARGET_REDUNDANCY_MISMATCH_MESSAGE, checkRedundancyFilterList);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
const hasOtherRow = rows.some((row) => row.id !== selfId);
|
|
648
|
+
|
|
649
|
+
// 3. No conflict — keep the inserted row; otherwise rollback and reject
|
|
650
|
+
if (!hasOtherRow) {
|
|
651
|
+
return generateResponse.success<T>(data);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
return failCreateTargetRedundancy(selfId, checkRedundancyFilterList);
|
|
329
655
|
};
|
|
330
656
|
|
|
331
657
|
export function validateWith<P extends object, V extends BaseValidator<P>>(ValidatorClass: new () => V) {
|
|
@@ -336,3 +662,33 @@ export function validateWith<P extends object, V extends BaseValidator<P>>(Valid
|
|
|
336
662
|
};
|
|
337
663
|
};
|
|
338
664
|
}
|
|
665
|
+
|
|
666
|
+
function formatZodValidationError(schemaName: string, error: ZodError): Error {
|
|
667
|
+
const errorList = error.issues
|
|
668
|
+
.map((issue, index) => {
|
|
669
|
+
const path = issue.path.length > 0 ? issue.path.join(".") : "(root)";
|
|
670
|
+
return ` ${index + 1}. ${path}: ${issue.message}`;
|
|
671
|
+
})
|
|
672
|
+
.join("\n");
|
|
673
|
+
const errorCount = error.issues.length;
|
|
674
|
+
return new Error(
|
|
675
|
+
`[${schemaName}] Validation failed (${errorCount} error${errorCount > 1 ? "s" : ""}):\n${errorList}`
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/** Zod-based counterpart to {@link validateWith} — requires `zod` as a peer dependency. */
|
|
680
|
+
export function validateWithSchema<T extends ZodType>(
|
|
681
|
+
schema: T,
|
|
682
|
+
schemaName = "Schema"
|
|
683
|
+
) {
|
|
684
|
+
type Parsed = T["_output"];
|
|
685
|
+
return <R>(fn: (validPayload: Parsed) => R | Promise<R>) => {
|
|
686
|
+
return (payload: Parsed): R | Promise<R> => {
|
|
687
|
+
const result = schema.safeParse(payload);
|
|
688
|
+
if (!result.success) {
|
|
689
|
+
throw formatZodValidationError(schemaName, result.error);
|
|
690
|
+
}
|
|
691
|
+
return fn(result.data);
|
|
692
|
+
};
|
|
693
|
+
};
|
|
694
|
+
}
|