target-supabase-sdk 0.2.21 → 0.2.23

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 (36) hide show
  1. package/dist/browser.js +1 -1
  2. package/dist/chunks/{task-queue-FygwujNS.js → task-queue-Ba8S21I6.js} +66 -20
  3. package/dist/chunks/task-queue-Ba8S21I6.js.map +1 -0
  4. package/dist/node.d.ts +2 -2
  5. package/dist/node.d.ts.map +1 -1
  6. package/dist/node.js +179 -19
  7. package/dist/node.js.map +1 -1
  8. package/dist/shared/feed/feed.build.d.ts +4 -2
  9. package/dist/shared/feed/feed.build.d.ts.map +1 -1
  10. package/dist/shared/feed/node/feed.access.d.ts +10 -2
  11. package/dist/shared/feed/node/feed.access.d.ts.map +1 -1
  12. package/dist/shared/feed/node/feed.service.d.ts +26 -1
  13. package/dist/shared/feed/node/feed.service.d.ts.map +1 -1
  14. package/dist/shared/feed/node/index.d.ts +3 -3
  15. package/dist/shared/feed/node/index.d.ts.map +1 -1
  16. package/dist/shared/http/fetch-retry.d.ts +2 -0
  17. package/dist/shared/http/fetch-retry.d.ts.map +1 -1
  18. package/dist/shared/media/node/media.service.d.ts +8 -2
  19. package/dist/shared/media/node/media.service.d.ts.map +1 -1
  20. package/dist/task/task-node.d.ts +3 -0
  21. package/dist/task/task-node.d.ts.map +1 -1
  22. package/dist/task/task.api.d.ts +14 -1
  23. package/dist/task/task.api.d.ts.map +1 -1
  24. package/package.json +1 -1
  25. package/src/node.ts +4 -0
  26. package/src/shared/feed/README.md +4 -3
  27. package/src/shared/feed/feed.build.ts +6 -3
  28. package/src/shared/feed/node/feed.access.ts +33 -6
  29. package/src/shared/feed/node/feed.service.ts +131 -4
  30. package/src/shared/feed/node/index.ts +7 -3
  31. package/src/shared/http/fetch-retry.ts +13 -2
  32. package/src/shared/media/README.md +4 -4
  33. package/src/shared/media/node/media.service.ts +77 -10
  34. package/src/task/task-node.ts +7 -1
  35. package/src/task/task.api.ts +62 -15
  36. package/dist/chunks/task-queue-FygwujNS.js.map +0 -1
@@ -93,10 +93,10 @@ Internal SDK code:
93
93
 
94
94
  ## Register flow (`registerMediaLink`)
95
95
 
96
- 1. **Dedup** — `category` + `value` + `name` via `findMediaLink`. If present → `{ id, created: false }`.
97
- 2. **Availability** — `storageProvider` must equal `localStorageProvider` (typically `LOCAL_STORAGE_PROVIDER` from the service `.env`); `locator` absolute and reachable.
98
- 3. **Probe** — stream SHA-256 / `stat` / MIME (or directory file count for `image-list`).
99
- 4. **Insert** — `buildMediaLinkDraft` → `postTarget` (race → re-find).
96
+ 1. **Dedup** — `category` + `value` + `name` via `findMediaLink`. Local locator: if present → `{ id, created: false }`. HTTP locator: patch `original` when storage/locator differ.
97
+ 2. **Availability** — local path: `storageProvider` must equal `localStorageProvider`; `locator` absolute and reachable. HTTP `locator`: require `localLocator` and probe that file on `localStorageProvider`.
98
+ 3. **Probe** — stream SHA-256 / `stat` / MIME (or directory file count for `image-list`) from the local file.
99
+ 4. **Insert** — `buildMediaLinkDraft` → `postTarget` (race → re-find / HTTP patch). Persisted `locator` may be the HTTP URL.
100
100
 
101
101
  ## Env (Node hosts)
102
102
 
@@ -1,6 +1,7 @@
1
- import { getPossibleTarget, isCreateTargetAlreadyExistsError, postTarget } from "../../../core.api";
2
- import type { Link } from "../../../link/link.interface";
1
+ import { getPossibleTarget, isCreateTargetAlreadyExistsError, postTarget, updateTarget } from "../../../core.api";
2
+ import { CategoryLink, type Link, type LinkDetails } from "../../../link/link.interface";
3
3
  import { createLogger } from "../../log/core/create-logger";
4
+ import { isHttpUrl } from "../../utils/fetch-url";
4
5
  import { buildMediaLinkDraft } from "../media.build";
5
6
  import { isMediaValue, type MediaValue } from "../media.interface";
6
7
  import { mediaLinkDedupFilters } from "../media.query";
@@ -35,6 +36,11 @@ export interface RegisterMediaLinkInput {
35
36
  locator: string;
36
37
  /** This host id for availability (e.g. `process.env.LOCAL_STORAGE_PROVIDER`). */
37
38
  localStorageProvider: string;
39
+ /**
40
+ * Local file to probe when `locator` is http(s) (OSS ingest).
41
+ * Availability uses this path + `localStorageProvider`.
42
+ */
43
+ localLocator?: string;
38
44
  description?: string;
39
45
  tagList?: string[];
40
46
  }
@@ -44,9 +50,53 @@ export interface RegisterMediaLinkResult {
44
50
  created: boolean;
45
51
  }
46
52
 
53
+ function mediaOriginalFromRow(row: { details?: unknown }): { storageProvider?: string; locator?: string } {
54
+ const details = row.details as LinkDetails | undefined;
55
+ const original = details?.original as { storageProvider?: string; locator?: string } | undefined;
56
+ return original ?? {};
57
+ }
58
+
59
+ async function patchMediaOriginal(
60
+ existing: Link,
61
+ storageProvider: string,
62
+ locator: string,
63
+ ): Promise<RegisterMediaLinkResult> {
64
+ const original = mediaOriginalFromRow(existing);
65
+ if (original.storageProvider === storageProvider && original.locator === locator) {
66
+ return { id: existing.id, created: false };
67
+ }
68
+ await updateTarget({
69
+ id: existing.id,
70
+ optimisticLockFilterList: [
71
+ { field: "category", operator: "eq", value: CategoryLink.LINK },
72
+ { field: "value", operator: "eq", value: existing.value },
73
+ ],
74
+ updateFn: (row) => {
75
+ const details = row.details as LinkDetails;
76
+ const prev = (details.original ?? {}) as Record<string, unknown>;
77
+ return {
78
+ details: {
79
+ ...details,
80
+ original: {
81
+ ...prev,
82
+ storageProvider,
83
+ locator,
84
+ },
85
+ },
86
+ };
87
+ },
88
+ });
89
+ logger.info("media original patched", {
90
+ topic: "register",
91
+ data: { id: existing.id, locator },
92
+ });
93
+ return { id: existing.id, created: false };
94
+ }
95
+
47
96
  /**
48
97
  * Register a media Link: dedup → availability → probe → `postTarget`.
49
- * Idempotent when `category`+`value`+`name` already exist.
98
+ * Idempotent when `category`+`value`+`name` already exist (local path).
99
+ * HTTP `locator` + `localLocator`: probe local file, persist remote URL; patch if name exists.
50
100
  *
51
101
  * **Node-only** — under `shared/media/node/`; do not import from the browser entry.
52
102
  */
@@ -59,19 +109,33 @@ export async function registerMediaLink(input: RegisterMediaLinkInput): Promise<
59
109
  throw new Error("registerMediaLink: name is empty");
60
110
  }
61
111
 
112
+ const storageProvider = input.storageProvider.trim();
113
+ const locator = input.locator.trim();
114
+ const localLocator = input.localLocator?.trim() ?? "";
115
+ const httpLocator = isHttpUrl(locator);
116
+
62
117
  const existing = await findMediaLink({ value: input.value, name });
63
- if (existing != null) {
118
+ if (existing != null && !httpLocator) {
64
119
  logger.info("media already registered", {
65
120
  topic: "dedup",
66
121
  data: { id: existing.id, value: input.value, name },
67
122
  });
68
123
  return { id: existing.id, created: false };
69
124
  }
125
+ if (existing != null && httpLocator) {
126
+ return patchMediaOriginal(existing, storageProvider, locator);
127
+ }
128
+
129
+ const probeStorageProvider = httpLocator ? input.localStorageProvider.trim() : storageProvider;
130
+ const probeLocator = httpLocator ? localLocator : locator;
131
+ if (httpLocator && probeLocator === "") {
132
+ throw new Error("registerMediaLink: localLocator is required when locator is http(s)");
133
+ }
70
134
 
71
135
  const availability = await checkMediaAvailability({
72
136
  value: input.value,
73
- storageProvider: input.storageProvider,
74
- locator: input.locator,
137
+ storageProvider: probeStorageProvider,
138
+ locator: probeLocator,
75
139
  localStorageProvider: input.localStorageProvider,
76
140
  });
77
141
  if (!availability.ok || availability.absolutePath == null) {
@@ -80,16 +144,16 @@ export async function registerMediaLink(input: RegisterMediaLinkInput): Promise<
80
144
 
81
145
  const probed = await probeMediaOriginal({
82
146
  value: input.value,
83
- storageProvider: input.storageProvider,
84
- locator: input.locator,
147
+ storageProvider: probeStorageProvider,
148
+ locator: probeLocator,
85
149
  absolutePath: availability.absolutePath,
86
150
  });
87
151
 
88
152
  const draft = buildMediaLinkDraft({
89
153
  value: input.value,
90
154
  name,
91
- storageProvider: probed.storageProvider,
92
- locator: probed.locator,
155
+ storageProvider: httpLocator ? storageProvider : probed.storageProvider,
156
+ locator: httpLocator ? locator : probed.locator,
93
157
  contentHash: probed.contentHash,
94
158
  size: probed.size,
95
159
  mimeType: probed.mimeType,
@@ -117,6 +181,9 @@ export async function registerMediaLink(input: RegisterMediaLinkInput): Promise<
117
181
  if (isCreateTargetAlreadyExistsError(error)) {
118
182
  const raced = await findMediaLink({ value: input.value, name });
119
183
  if (raced != null) {
184
+ if (httpLocator) {
185
+ return patchMediaOriginal(raced, storageProvider, locator);
186
+ }
120
187
  logger.info("media register race — using existing", {
121
188
  topic: "dedup",
122
189
  data: { id: raced.id, value: input.value, name },
@@ -2,7 +2,7 @@ import { isOptimisticLockResponse } from "../core.api";
2
2
  import { BaseNodeRuntime, LOG_TOPIC_NODE, type NodeLoopContext } from "../node/node-runtime.base";
3
3
  import { createLogger, createScope, type LoggerWithScope, withModule } from "../shared/log";
4
4
  import { getErrorMessage, toError } from "../shared/utils/error.utils";
5
- import { patchChangeTaskStatus, patchClaimTask } from "./task.api";
5
+ import { type PatchClaimTaskPayload, patchChangeTaskStatus, patchClaimTask } from "./task.api";
6
6
  import { CRITICAL_UNKNOWN_TASK_TRACE_ID, LOG_TOPIC_TASK } from "./task.constant";
7
7
  import { TaskStatusAction } from "./task.interface";
8
8
  import { TaskManager, type TaskRunResult } from "./task-manager";
@@ -19,6 +19,11 @@ class TaskNode extends BaseNodeRuntime {
19
19
  return [...this._availableTaskList];
20
20
  }
21
21
 
22
+ /** Optional claim filters passed to {@link patchClaimTask} (e.g. name prefix per task type). */
23
+ protected getPatchClaimTaskOptions(): Pick<PatchClaimTaskPayload, "namePrefixList"> {
24
+ return {};
25
+ }
26
+
22
27
  constructor() {
23
28
  super("taskNode");
24
29
  }
@@ -63,6 +68,7 @@ class TaskNode extends BaseNodeRuntime {
63
68
  nodeId,
64
69
  availableTaskList: [...availableTaskList],
65
70
  traceId: loopTraceId,
71
+ ...this.getPatchClaimTaskOptions(),
66
72
  });
67
73
  if (claimError) {
68
74
  if (isOptimisticLockResponse(claimError)) {
@@ -5,12 +5,15 @@ import {
5
5
  OPTIMISTIC_LOCK_ERROR_CODE,
6
6
  OPTIMISTIC_LOCK_FAILED_MESSAGE,
7
7
  type QueryFilter,
8
+ type TargetFilterBuilder,
8
9
  updateTargetDetails,
9
10
  validateWithSchema,
10
11
  } from "../core.api";
11
12
  import { generateResponse, type SupabaseResponse } from "../core.interface";
12
13
  import { createLogger, type LoggerWithScope } from "../shared/log";
13
14
  import { getErrorMessage } from "../shared/utils/error.utils";
15
+ import { escapeIlikePattern } from "../shared/utils/postgrest.utils";
16
+ import { supabase } from "../supabase";
14
17
  import { LOG_TOPIC_TASK } from "./task.constant";
15
18
  import { CategoryTask, ResultCode, type Task, type TaskDetails, TaskStatus, TaskStatusAction } from "./task.interface";
16
19
 
@@ -112,12 +115,28 @@ export type PatchTaskProgressPayload = z.infer<typeof patchTaskProgressSchema>;
112
115
 
113
116
  export const patchClaimTaskSchema = z.object({
114
117
  nodeId: nodeIdSchema,
118
+ /** Task `value` must be in this list (always applied). */
115
119
  availableTaskList: z.array(z.string()).min(1),
116
120
  traceId: traceIdSchema,
121
+ /**
122
+ * Optional second axis on `name` (AND with `availableTaskList`, not coupled to `value`).
123
+ * When non-empty: `name` must equal a prefix or start with `prefix:`; query uses PostgREST `ilike`.
124
+ */
125
+ namePrefixList: z.array(z.string().trim().min(1)).optional(),
117
126
  });
118
127
 
119
128
  export type PatchClaimTaskPayload = z.infer<typeof patchClaimTaskSchema>;
120
129
 
130
+ /** `name === prefix` or `name.startsWith(prefix + ':')` via `name ilike 'prefix%'`. */
131
+ function buildClaimTaskNamePrefixFilterBuilder(namePrefixes: string[]): TargetFilterBuilder {
132
+ const base = supabase.client.from("target").select("*");
133
+ if (namePrefixes.length === 1) {
134
+ return base.ilike("name", `${escapeIlikePattern(namePrefixes[0])}%`);
135
+ }
136
+ const nameOrParts = namePrefixes.map((prefix) => `name.ilike.${escapeIlikePattern(prefix)}%`);
137
+ return base.or(nameOrParts.join(","));
138
+ }
139
+
121
140
  // ─── Optimistic lock helpers ─────────────────────────────────────────────────
122
141
 
123
142
  function lockOnStatus(...allowed: TaskStatus[]): QueryFilter[] {
@@ -338,7 +357,11 @@ export const patchTaskProgress = validateWithSchema(
338
357
  /**
339
358
  * Discover and claim one TODO task for this node.
340
359
  *
341
- * Flow: oldest TODO in `availableTaskList` (FIFO by `created_at`) → {@link claimTaskById}.
360
+ * Match criteria (all required): `details.status = TODO`, `value ∈ availableTaskList`, and when
361
+ * `namePrefixList` is non-empty, `name` matches any listed prefix. Those filters are independent
362
+ * (AND); omitting `namePrefixList` does not relax the `value` constraint.
363
+ *
364
+ * Flow: oldest matching row (FIFO by `created_at`) → {@link claimTaskById}.
342
365
  * Equivalent to `patchChangeTaskStatus({ id, action: CLAIM, nodeId })` when `id` is already known.
343
366
  *
344
367
  * Returns `data: null` when no TODO task matches.
@@ -347,25 +370,49 @@ export const patchTaskProgress = validateWithSchema(
347
370
  export const patchClaimTask = validateWithSchema(
348
371
  patchClaimTaskSchema,
349
372
  "patchClaimTaskSchema",
350
- )(async ({ nodeId, availableTaskList, traceId }): Promise<SupabaseResponse<Task | null>> => {
373
+ )(async ({ nodeId, availableTaskList, traceId, namePrefixList }): Promise<SupabaseResponse<Task | null>> => {
351
374
  const logger = createLogger({ module: "patchClaimTask", traceId, labels: { nodeId } });
352
375
 
376
+ const namePrefixes = (namePrefixList ?? []).map((prefix) => prefix.trim()).filter((prefix) => prefix !== "");
377
+
378
+ function taskNameMatchesPrefixList(name: string): boolean {
379
+ return namePrefixes.some((prefix) => name === prefix || name.startsWith(`${prefix}:`));
380
+ }
381
+
353
382
  let todoTask: Task;
354
383
  try {
355
- const { data: taskCandidates } = await getTargetList<Task>({
356
- category: CategoryTask.TASK,
357
- filterList: [
358
- { field: TASK_STATUS_FIELD, operator: "eq", value: TaskStatus.TODO },
359
- { field: "value", operator: "in", value: availableTaskList },
360
- ],
361
- limit: 1,
362
- orderBy: { field: "created_at", ascending: true },
363
- });
364
- const possibleTask = taskCandidates?.[0] ?? null;
365
- if (possibleTask == null) {
366
- return generateResponse.success(null);
384
+ if (namePrefixes.length === 0) {
385
+ const { data: taskCandidates } = await getTargetList<Task>({
386
+ category: CategoryTask.TASK,
387
+ filterList: [
388
+ { field: TASK_STATUS_FIELD, operator: "eq", value: TaskStatus.TODO },
389
+ { field: "value", operator: "in", value: availableTaskList },
390
+ ],
391
+ limit: 1,
392
+ orderBy: { field: "created_at", ascending: true },
393
+ });
394
+ const possibleTask = taskCandidates?.[0] ?? null;
395
+ if (possibleTask == null) {
396
+ return generateResponse.success(null);
397
+ }
398
+ todoTask = possibleTask;
399
+ } else {
400
+ const { data: taskCandidates } = await getTargetList<Task>({
401
+ category: CategoryTask.TASK,
402
+ filterList: [
403
+ { field: TASK_STATUS_FIELD, operator: "eq", value: TaskStatus.TODO },
404
+ { field: "value", operator: "in", value: availableTaskList },
405
+ ],
406
+ limit: 1,
407
+ orderBy: { field: "created_at", ascending: true },
408
+ filterBuilder: buildClaimTaskNamePrefixFilterBuilder(namePrefixes),
409
+ });
410
+ const possibleTask = taskCandidates?.find((task) => taskNameMatchesPrefixList(task.name)) ?? null;
411
+ if (possibleTask == null) {
412
+ return generateResponse.success(null);
413
+ }
414
+ todoTask = possibleTask;
367
415
  }
368
- todoTask = possibleTask;
369
416
  } catch (error) {
370
417
  const message = getErrorMessage(error);
371
418
  logger.error("獲取 TODO 任務失敗", { topic: LOG_TOPIC_TASK, data: { error: message, availableTaskList } });