target-supabase-sdk 0.2.20 → 0.2.22

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 (45) hide show
  1. package/dist/browser.d.ts +1 -1
  2. package/dist/browser.d.ts.map +1 -1
  3. package/dist/browser.js +1 -1
  4. package/dist/chunks/{task-queue-CqmIVCBi.js → task-queue-DWFe0rCS.js} +20 -22
  5. package/dist/chunks/task-queue-DWFe0rCS.js.map +1 -0
  6. package/dist/node.d.ts +2 -2
  7. package/dist/node.d.ts.map +1 -1
  8. package/dist/node.js +174 -19
  9. package/dist/node.js.map +1 -1
  10. package/dist/shared/feed/feed.build.d.ts +4 -2
  11. package/dist/shared/feed/feed.build.d.ts.map +1 -1
  12. package/dist/shared/feed/node/feed.access.d.ts +10 -2
  13. package/dist/shared/feed/node/feed.access.d.ts.map +1 -1
  14. package/dist/shared/feed/node/feed.service.d.ts +26 -1
  15. package/dist/shared/feed/node/feed.service.d.ts.map +1 -1
  16. package/dist/shared/feed/node/index.d.ts +3 -3
  17. package/dist/shared/feed/node/index.d.ts.map +1 -1
  18. package/dist/shared/http/fetch-retry.d.ts +2 -0
  19. package/dist/shared/http/fetch-retry.d.ts.map +1 -1
  20. package/dist/shared/media/node/media.service.d.ts +8 -2
  21. package/dist/shared/media/node/media.service.d.ts.map +1 -1
  22. package/dist/shared/review/index.d.ts +1 -1
  23. package/dist/shared/review/index.d.ts.map +1 -1
  24. package/dist/shared/review/review.build.d.ts +3 -6
  25. package/dist/shared/review/review.build.d.ts.map +1 -1
  26. package/dist/shared/review/review.interface.d.ts +2 -2
  27. package/dist/shared/review/review.query.d.ts +2 -2
  28. package/dist/shared/review/review.query.d.ts.map +1 -1
  29. package/package.json +1 -1
  30. package/src/browser.ts +1 -1
  31. package/src/node.ts +4 -0
  32. package/src/shared/feed/README.md +4 -3
  33. package/src/shared/feed/feed.build.ts +6 -3
  34. package/src/shared/feed/node/feed.access.ts +33 -6
  35. package/src/shared/feed/node/feed.service.ts +131 -4
  36. package/src/shared/feed/node/index.ts +7 -3
  37. package/src/shared/http/fetch-retry.ts +13 -2
  38. package/src/shared/media/README.md +4 -4
  39. package/src/shared/media/node/media.service.ts +77 -10
  40. package/src/shared/review/README.md +15 -16
  41. package/src/shared/review/index.ts +1 -1
  42. package/src/shared/review/review.build.ts +8 -13
  43. package/src/shared/review/review.interface.ts +2 -2
  44. package/src/shared/review/review.query.ts +4 -4
  45. package/dist/chunks/task-queue-CqmIVCBi.js.map +0 -1
@@ -28,6 +28,8 @@ export interface FetchRetryOptions {
28
28
  /** When set, retry/success lines are logged via SDK logger. */
29
29
  logger?: LoggerWithScope;
30
30
  hint?: string;
31
+ /** Override global `fetch` (e.g. proxied outbound fetch). Ignores `dispatcher`. */
32
+ fetch?: (url: string, init?: RequestInit) => Promise<Response>;
31
33
  }
32
34
 
33
35
  function backoffMs(retryBaseMs: number, attempt: number, response?: Response, maxBackoffMs?: number): number {
@@ -47,11 +49,20 @@ function backoffMs(retryBaseMs: number, attempt: number, response?: Response, ma
47
49
  return delayMs;
48
50
  }
49
51
 
50
- async function fetchOnce(url: string, init: RequestInit, timeoutMs: number, dispatcher?: unknown): Promise<Response> {
52
+ async function fetchOnce(
53
+ url: string,
54
+ init: RequestInit,
55
+ timeoutMs: number,
56
+ dispatcher?: unknown,
57
+ fetchImpl?: (url: string, init?: RequestInit) => Promise<Response>,
58
+ ): Promise<Response> {
51
59
  const options: RequestInit & { dispatcher?: unknown } = {
52
60
  ...init,
53
61
  signal: AbortSignal.timeout(timeoutMs),
54
62
  };
63
+ if (fetchImpl != null) {
64
+ return fetchImpl(url, options);
65
+ }
55
66
  if (dispatcher != null) {
56
67
  options.dispatcher = dispatcher;
57
68
  }
@@ -76,7 +87,7 @@ export async function fetchWithRetry(
76
87
 
77
88
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
78
89
  try {
79
- const response = await fetchOnce(url, buildInit(), options.timeoutMs, options.dispatcher);
90
+ const response = await fetchOnce(url, buildInit(), options.timeoutMs, options.dispatcher, options.fetch);
80
91
 
81
92
  if (!response.ok && isRetryableStatus(response.status) && attempt < maxAttempts) {
82
93
  const delayMs = backoffMs(retryBaseMs, attempt, response, options.maxBackoffMs);
@@ -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 @@
2
2
 
3
3
  Review is a **convention on `Link`**, not a new Target category (`category` stays `link`).
4
4
 
5
- A **Review** records **one user interaction** with a **subject Target** (for example: open, play, complete, rate, dismiss). **Each interaction creates a new Review row.** The subject is linked by storing the Target id on `Link.name` (`refId` at create time). **Many review Links may share the same `refId`** — the SDK does not define dedup or idempotent create for reviews.
5
+ A **Review** records **one user interaction** (for example: open, play, complete, rate, dismiss). **Each interaction creates a new Review row.** **Many review Links may share the same `Link.name`** — the SDK does not define dedup or idempotent create for reviews.
6
6
 
7
7
  All review code lives under **`src/shared/review/`**. There is no Node-only ingest layer (unlike feed/media register flows) — create drafts on any host and persist with `postTarget` / `createTarget`.
8
8
 
@@ -19,12 +19,12 @@ src/shared/review/
19
19
 
20
20
  | Consumer import | What you get |
21
21
  |-----------------|--------------|
22
- | `from "target-supabase-sdk"` | Draft builder, constants, list filters |
22
+ | `from "target-supabase-sdk"` | Draft builder, list filters |
23
23
 
24
24
  ```typescript
25
25
  import {
26
26
  buildReviewLinkDraft,
27
- reviewLinksForTargetFilters,
27
+ reviewLinksForNameFilters,
28
28
  } from "target-supabase-sdk";
29
29
  ```
30
30
 
@@ -34,12 +34,12 @@ import {
34
34
 
35
35
  | Concept | Where it lives |
36
36
  |---------|----------------|
37
- | Subject Target | `Link.name` — the associated Target id (`refId` when building the draft) |
37
+ | Grouping key | `Link.name` — caller-defined (often a subject Target id, not required) |
38
38
  | Interaction body | `Link.details.original` — caller-defined JSON-serializable payload |
39
39
  | Review kind | Fixed `Link.value` / `details.loaderKey` = `"review"` |
40
40
  | Labels | Optional `tagList` (e.g. interaction type, client, experiment) |
41
41
 
42
- Each persisted Review is its **own** Target row (its own `id`). Repeating the same `refId` on `name` is expected when listing interaction history for one subject.
42
+ Each persisted Review is its **own** Target row (its own `id`). Repeating the same `name` is expected when listing interaction history under one grouping key.
43
43
 
44
44
  `details.original` is intentionally **untyped** (`unknown`) in the SDK so products can store scores, dwell time, UI state, model output, etc., without a shared schema in this package.
45
45
 
@@ -51,8 +51,7 @@ Built via `buildReviewLinkDraft` → `buildLinkTargetDraft`.
51
51
 
52
52
  | Create field | Required | Link field |
53
53
  |--------------|----------|------------|
54
- | `value` | yes | `value` — must be `"review"` |
55
- | `refId` | yes | `name` — subject Target id |
54
+ | `name` | yes | `name` |
56
55
  | `original` | yes | `details.original` |
57
56
  | `tagList` | no | `tagList` (default `[]`) |
58
57
  | `description` | no | `details.description` (default `""`) |
@@ -61,8 +60,9 @@ Fixed at build time (not on create input):
61
60
 
62
61
  | Link field | Value |
63
62
  |------------|--------|
63
+ | `value` | `"review"` |
64
64
  | `category` | `link` |
65
- | `details.loaderKey` | `"review"` (same as `value`) |
65
+ | `details.loaderKey` | `"review"` |
66
66
  | `details.manifestVersion` | `0` |
67
67
  | `details.preview` | `""` |
68
68
 
@@ -75,8 +75,7 @@ import { buildReviewLinkDraft } from "target-supabase-sdk";
75
75
  import { postTarget } from "target-supabase-sdk"; // or your create API
76
76
 
77
77
  const draft = buildReviewLinkDraft({
78
- value: "review",
79
- refId: subjectTargetId,
78
+ name: subjectTargetId,
80
79
  original: {
81
80
  action: "complete",
82
81
  progress: 1,
@@ -88,23 +87,23 @@ const draft = buildReviewLinkDraft({
88
87
  await postTarget(draft);
89
88
  ```
90
89
 
91
- Every interaction: build a new draft (same `refId` is fine) and insert again.
90
+ Every interaction: build a new draft (same `name` is fine) and insert again.
92
91
 
93
92
  ---
94
93
 
95
94
  ## Query
96
95
 
97
- List review Links for one subject Target:
96
+ List review Links sharing a `Link.name`:
98
97
 
99
98
  ```typescript
100
- import { reviewLinksForTargetFilters, scanTargetList } from "target-supabase-sdk";
99
+ import { reviewLinksForNameFilters, scanTargetList } from "target-supabase-sdk";
101
100
 
102
101
  const reviews = await scanTargetList({
103
- filters: reviewLinksForTargetFilters(subjectTargetId),
102
+ filters: reviewLinksForNameFilters(subjectTargetId),
104
103
  });
105
104
  ```
106
105
 
107
- Filters: `category=link`, `value=review`, `name=refId`. Use pagination / `scanTargetList` when history is long.
106
+ Filters: `category=link`, `value=review`, `name=<trimmed name>`. Use pagination / `scanTargetList` when history is long.
108
107
 
109
108
  There is **no** SDK helper for dedup or “find existing review” — callers always create new rows unless they add their own policy.
110
109
 
@@ -114,7 +113,7 @@ There is **no** SDK helper for dedup or “find existing review” — callers a
114
113
 
115
114
  ```typescript
116
115
  if (link.category === "link" && link.value === "review") {
117
- const subjectTargetId = link.name;
116
+ const groupingKey = link.name;
118
117
  const payload = link.details.original;
119
118
  }
120
119
  ```
@@ -4,4 +4,4 @@
4
4
 
5
5
  export type { BuildReviewLinkDraftInput } from "./review.build";
6
6
  export { buildReviewLinkDraft } from "./review.build";
7
- export { reviewLinksForTargetFilters } from "./review.query";
7
+ export { reviewLinksForNameFilters } from "./review.query";
@@ -1,13 +1,11 @@
1
1
  import type { TargetDraft } from "../../core.interface";
2
2
  import { buildLinkTargetDraft } from "../../link/link.build";
3
3
  import type { Link } from "../../link/link.interface";
4
- import { isReviewValue, REVIEW_VALUE, type ReviewValue } from "./review.interface";
4
+ import { REVIEW_VALUE } from "./review.interface";
5
5
 
6
6
  export interface BuildReviewLinkDraftInput {
7
- /** Must be {@link REVIEW_VALUE}. */
8
- value: ReviewValue;
9
- /** Associated Target id — written to `Link.name`. */
10
- refId: string;
7
+ /** Written to `Link.name` (caller-defined; often a subject Target id). */
8
+ name: string;
11
9
  /** Interaction payload — written to `Link.details.original`. */
12
10
  original: unknown;
13
11
  tagList?: string[];
@@ -16,19 +14,16 @@ export interface BuildReviewLinkDraftInput {
16
14
 
17
15
  /**
18
16
  * Assemble a review {@link Link} draft (`category=link`, `loaderKey === value === "review"`).
19
- * Does not validate `refId` existence or write to Supabase.
17
+ * Does not validate `name` or write to Supabase.
20
18
  */
21
19
  export function buildReviewLinkDraft(input: BuildReviewLinkDraftInput): TargetDraft<Link> {
22
- if (!isReviewValue(input.value)) {
23
- throw new Error(`buildReviewLinkDraft: unsupported review value: ${String(input.value)}`);
24
- }
25
- const refId = input.refId.trim();
26
- if (refId === "") {
27
- throw new Error("buildReviewLinkDraft: refId is empty");
20
+ const name = input.name.trim();
21
+ if (name === "") {
22
+ throw new Error("buildReviewLinkDraft: name is empty");
28
23
  }
29
24
 
30
25
  return buildLinkTargetDraft({
31
- name: refId,
26
+ name,
32
27
  value: REVIEW_VALUE,
33
28
  description: input.description ?? "",
34
29
  preview: "",
@@ -2,8 +2,8 @@
2
2
  * Review Link protocol — Link rows with `value` / `loaderKey` === {@link REVIEW_VALUE}.
3
3
  * No separate Target category; category remains `link`.
4
4
  *
5
- * A Review records one user interaction with a subject Target (`Link.name` = that Target's id).
6
- * Many review Links may share the same subject id.
5
+ * A Review records one user interaction; `Link.name` is caller-defined (often a subject Target id).
6
+ * Many review Links may share the same `name`.
7
7
  */
8
8
 
9
9
  export const REVIEW_VALUE = "review" as const;
@@ -3,14 +3,14 @@ import { CategoryLink } from "../../link/link.interface";
3
3
  import { REVIEW_VALUE } from "./review.interface";
4
4
 
5
5
  /**
6
- * Filters for all review Links whose subject Target is `refId` (`Link.name === refId`).
6
+ * Filters for review Links with `Link.name === name`.
7
7
  * Many rows may match — the SDK does not define dedup for reviews.
8
8
  */
9
- export function reviewLinksForTargetFilters(refId: string): QueryFilter[] {
10
- const name = refId.trim();
9
+ export function reviewLinksForNameFilters(name: string): QueryFilter[] {
10
+ const trimmed = name.trim();
11
11
  return [
12
12
  { field: "category", operator: "eq", value: CategoryLink.LINK },
13
13
  { field: "value", operator: "eq", value: REVIEW_VALUE },
14
- { field: "name", operator: "eq", value: name },
14
+ { field: "name", operator: "eq", value: trimmed },
15
15
  ];
16
16
  }