target-supabase-sdk 0.2.18 → 0.2.20
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 +28 -0
- package/dist/browser.d.ts +4 -0
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +1 -1
- package/dist/chunks/{task-queue-DufG7PlX.js → task-queue-CqmIVCBi.js} +165 -2
- package/dist/chunks/task-queue-CqmIVCBi.js.map +1 -0
- package/dist/node.d.ts +2 -0
- package/dist/node.d.ts.map +1 -1
- package/dist/node.js +251 -10
- package/dist/node.js.map +1 -1
- package/dist/shared/feed/feed.build.d.ts +33 -0
- package/dist/shared/feed/feed.build.d.ts.map +1 -0
- package/dist/shared/feed/feed.interface.d.ts +35 -0
- package/dist/shared/feed/feed.interface.d.ts.map +1 -0
- package/dist/shared/feed/feed.name.d.ts +8 -0
- package/dist/shared/feed/feed.name.d.ts.map +1 -0
- package/dist/shared/feed/feed.query.d.ts +8 -0
- package/dist/shared/feed/feed.query.d.ts.map +1 -0
- package/dist/shared/feed/index.d.ts +13 -0
- package/dist/shared/feed/index.d.ts.map +1 -0
- package/dist/shared/feed/node/feed.access.d.ts +34 -0
- package/dist/shared/feed/node/feed.access.d.ts.map +1 -0
- package/dist/shared/feed/node/feed.service.d.ts +23 -0
- package/dist/shared/feed/node/feed.service.d.ts.map +1 -0
- package/dist/shared/feed/node/index.d.ts +11 -0
- package/dist/shared/feed/node/index.d.ts.map +1 -0
- package/dist/shared/review/index.d.ts +7 -0
- package/dist/shared/review/index.d.ts.map +1 -0
- package/dist/shared/review/review.build.d.ts +19 -0
- package/dist/shared/review/review.build.d.ts.map +1 -0
- package/dist/shared/review/review.interface.d.ts +14 -0
- package/dist/shared/review/review.interface.d.ts.map +1 -0
- package/dist/shared/review/review.query.d.ts +7 -0
- package/dist/shared/review/review.query.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/browser.ts +10 -1
- package/src/node.ts +14 -0
- package/src/shared/feed/README.md +163 -0
- package/src/shared/feed/feed.build.ts +101 -0
- package/src/shared/feed/feed.interface.ts +50 -0
- package/src/shared/feed/feed.name.ts +20 -0
- package/src/shared/feed/feed.query.ts +13 -0
- package/src/shared/feed/index.ts +13 -0
- package/src/shared/feed/node/feed.access.ts +134 -0
- package/src/shared/feed/node/feed.service.ts +183 -0
- package/src/shared/feed/node/index.ts +11 -0
- package/src/shared/review/README.md +128 -0
- package/src/shared/review/index.ts +7 -0
- package/src/shared/review/review.build.ts +39 -0
- package/src/shared/review/review.interface.ts +19 -0
- package/src/shared/review/review.query.ts +16 -0
- package/dist/chunks/task-queue-DufG7PlX.js.map +0 -1
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Normalize `platformName` so `Link.name` stays `{platform}.{feedKey}` with a single separator dot. */
|
|
2
|
+
export function sanitizeFeedPlatformName(platformName: string): string {
|
|
3
|
+
return platformName.trim().replaceAll(".", " ");
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Default dedup `Link.name` for feed Links: `{platformName}.{feedKey}`.
|
|
8
|
+
* Dots in `platformName` are replaced with spaces before joining (see {@link sanitizeFeedPlatformName}).
|
|
9
|
+
*/
|
|
10
|
+
export function buildFeedLinkName(platformName: string, feedKey: string): string {
|
|
11
|
+
const platform = sanitizeFeedPlatformName(platformName);
|
|
12
|
+
const key = feedKey.trim();
|
|
13
|
+
if (platform === "") {
|
|
14
|
+
throw new Error("buildFeedLinkName: platformName is empty");
|
|
15
|
+
}
|
|
16
|
+
if (key === "") {
|
|
17
|
+
throw new Error("buildFeedLinkName: feedKey is empty");
|
|
18
|
+
}
|
|
19
|
+
return `${platform}.${key}`;
|
|
20
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { QueryFilter } from "../../core.interface";
|
|
2
|
+
import { CategoryLink } from "../../link/link.interface";
|
|
3
|
+
import type { FeedValue } from "./feed.interface";
|
|
4
|
+
|
|
5
|
+
/** Dedup key: `category=link` + `value` + `name` (matches register / find). */
|
|
6
|
+
export function feedLinkDedupFilters(input: { value: FeedValue; name: string }): QueryFilter[] {
|
|
7
|
+
const name = input.name.trim();
|
|
8
|
+
return [
|
|
9
|
+
{ field: "category", operator: "eq", value: CategoryLink.LINK },
|
|
10
|
+
{ field: "value", operator: "eq", value: input.value },
|
|
11
|
+
{ field: "name", operator: "eq", value: name },
|
|
12
|
+
];
|
|
13
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feed Link — browser-safe public barrel only.
|
|
3
|
+
*
|
|
4
|
+
* Node-only code lives in `./node/` and is exported from `src/node.ts`,
|
|
5
|
+
* never from this file.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type { BuildFeedLinkDraftInput } from "./feed.build";
|
|
9
|
+
export { buildFeedLinkDraft } from "./feed.build";
|
|
10
|
+
export type { FeedOriginal, FeedSource, FeedValue } from "./feed.interface";
|
|
11
|
+
export { FEED_VALUES, isFeedValue } from "./feed.interface";
|
|
12
|
+
export { buildFeedLinkName } from "./feed.name";
|
|
13
|
+
export { feedLinkDedupFilters } from "./feed.query";
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { stat } from "node:fs/promises";
|
|
2
|
+
import { isAbsolute, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { fetchWithRetry } from "../../http/fetch-retry";
|
|
5
|
+
import { isHttpUrl } from "../../utils/fetch-url";
|
|
6
|
+
import type { FeedValue } from "../feed.interface";
|
|
7
|
+
|
|
8
|
+
export interface CheckFeedLocalAvailabilityInput {
|
|
9
|
+
value: FeedValue;
|
|
10
|
+
storageProvider: string;
|
|
11
|
+
locator: string;
|
|
12
|
+
/** This host id (e.g. `process.env.LOCAL_STORAGE_PROVIDER`). */
|
|
13
|
+
localStorageProvider: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface FeedAvailabilityResult {
|
|
17
|
+
ok: boolean;
|
|
18
|
+
/** Absolute path when the locator was resolved on this host. */
|
|
19
|
+
absolutePath?: string;
|
|
20
|
+
reason?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function sameStorageHost(storageProvider: string, localStorageProvider: string): boolean {
|
|
24
|
+
return storageProvider.trim() === localStorageProvider.trim();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isFeedDirectoryValue(value: FeedValue): boolean {
|
|
28
|
+
return value === "feed.image-list";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Local reachability for feed ingest (`.local` source).
|
|
33
|
+
*
|
|
34
|
+
* - Requires `storageProvider === localStorageProvider`.
|
|
35
|
+
* - `locator` must be an absolute path.
|
|
36
|
+
* - `feed.image-list`: directory exists.
|
|
37
|
+
* - Other feed values: non-empty file.
|
|
38
|
+
*
|
|
39
|
+
* **Node-only**
|
|
40
|
+
*/
|
|
41
|
+
export async function checkFeedLocalAvailability(
|
|
42
|
+
input: CheckFeedLocalAvailabilityInput,
|
|
43
|
+
): Promise<FeedAvailabilityResult> {
|
|
44
|
+
const storageProvider = input.storageProvider.trim();
|
|
45
|
+
const localStorageProvider = input.localStorageProvider.trim();
|
|
46
|
+
const locator = input.locator.trim();
|
|
47
|
+
|
|
48
|
+
if (storageProvider === "" || localStorageProvider === "") {
|
|
49
|
+
return { ok: false, reason: "storageProvider and localStorageProvider are required" };
|
|
50
|
+
}
|
|
51
|
+
if (locator === "") {
|
|
52
|
+
return { ok: false, reason: "locator is empty" };
|
|
53
|
+
}
|
|
54
|
+
if (!sameStorageHost(storageProvider, localStorageProvider)) {
|
|
55
|
+
return {
|
|
56
|
+
ok: false,
|
|
57
|
+
reason: `storageProvider not on this host (${storageProvider} !== ${localStorageProvider})`,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
if (!isAbsolute(locator)) {
|
|
61
|
+
return { ok: false, reason: `locator must be an absolute path: ${locator}` };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const absolutePath = resolve(locator);
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const info = await stat(absolutePath);
|
|
68
|
+
if (isFeedDirectoryValue(input.value)) {
|
|
69
|
+
if (!info.isDirectory()) {
|
|
70
|
+
return { ok: false, reason: `feed.image-list locator is not a directory: ${absolutePath}` };
|
|
71
|
+
}
|
|
72
|
+
return { ok: true, absolutePath };
|
|
73
|
+
}
|
|
74
|
+
if (!info.isFile()) {
|
|
75
|
+
return { ok: false, reason: `locator is not a file: ${absolutePath}` };
|
|
76
|
+
}
|
|
77
|
+
if (info.size <= 0) {
|
|
78
|
+
return { ok: false, reason: `file is empty: ${absolutePath}` };
|
|
79
|
+
}
|
|
80
|
+
return { ok: true, absolutePath };
|
|
81
|
+
} catch (error) {
|
|
82
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
83
|
+
return { ok: false, reason: `locator not reachable: ${absolutePath} (${message})` };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const OSS_CHECK_TIMEOUT_MS = 15_000;
|
|
88
|
+
const OSS_CHECK_MAX_ATTEMPTS = 2;
|
|
89
|
+
|
|
90
|
+
async function probeHttpLocator(locator: string, method: "HEAD" | "GET"): Promise<Response> {
|
|
91
|
+
return fetchWithRetry(
|
|
92
|
+
locator,
|
|
93
|
+
{
|
|
94
|
+
method,
|
|
95
|
+
...(method === "GET" ? { headers: { Range: "bytes=0-0" } } : {}),
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
label: "feed-oss-check",
|
|
99
|
+
timeoutMs: OSS_CHECK_TIMEOUT_MS,
|
|
100
|
+
maxAttempts: OSS_CHECK_MAX_ATTEMPTS,
|
|
101
|
+
},
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Remote reachability for feed ingest (`.oss` source) via HTTP(S).
|
|
107
|
+
*
|
|
108
|
+
* Tries `HEAD`, then `GET` with `Range: bytes=0-0` when `HEAD` is not allowed.
|
|
109
|
+
*
|
|
110
|
+
* **Node-only**
|
|
111
|
+
*/
|
|
112
|
+
export async function checkFeedOssAvailability(locator: string): Promise<FeedAvailabilityResult> {
|
|
113
|
+
const trimmed = locator.trim();
|
|
114
|
+
if (trimmed === "") {
|
|
115
|
+
return { ok: false, reason: "locator is empty" };
|
|
116
|
+
}
|
|
117
|
+
if (!isHttpUrl(trimmed)) {
|
|
118
|
+
return { ok: false, reason: `oss locator must be an http(s) URL: ${trimmed}` };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
let response = await probeHttpLocator(trimmed, "HEAD");
|
|
123
|
+
if (response.status === 405 || response.status === 501) {
|
|
124
|
+
response = await probeHttpLocator(trimmed, "GET");
|
|
125
|
+
}
|
|
126
|
+
if (response.ok || (response.status >= 200 && response.status < 400)) {
|
|
127
|
+
return { ok: true };
|
|
128
|
+
}
|
|
129
|
+
return { ok: false, reason: `oss locator HTTP ${response.status}: ${trimmed}` };
|
|
130
|
+
} catch (error) {
|
|
131
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
132
|
+
return { ok: false, reason: `oss locator request failed: ${trimmed} (${message})` };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { getTarget, updateTarget } from "../../../core.api";
|
|
2
|
+
import type { Target } from "../../../core.interface";
|
|
3
|
+
import { CategoryLink, type Link, type LinkDetails } from "../../../link/link.interface";
|
|
4
|
+
import { createLogger } from "../../log/core/create-logger";
|
|
5
|
+
import { buildFeedTagList, resolveFeedOriginalStorageProvider } from "../feed.build";
|
|
6
|
+
import {
|
|
7
|
+
type FeedIngestSource,
|
|
8
|
+
type FeedOriginal,
|
|
9
|
+
type FeedValue,
|
|
10
|
+
isFeedIngestSource,
|
|
11
|
+
isFeedValue,
|
|
12
|
+
} from "../feed.interface";
|
|
13
|
+
import { checkFeedLocalAvailability, checkFeedOssAvailability } from "./feed.access";
|
|
14
|
+
|
|
15
|
+
const logger = createLogger({ module: "feed" });
|
|
16
|
+
|
|
17
|
+
export interface RegisterFeedInput {
|
|
18
|
+
/** Existing feed Link row id (must still be `.public` unless already ingested with same payload). */
|
|
19
|
+
id: string;
|
|
20
|
+
source: FeedIngestSource;
|
|
21
|
+
storageProvider: string;
|
|
22
|
+
locator: string;
|
|
23
|
+
/** Required when `source` is `.local` (e.g. `process.env.LOCAL_STORAGE_PROVIDER`). Ignored for `.oss`. */
|
|
24
|
+
localStorageProvider?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface RegisterFeedResult {
|
|
28
|
+
id: string;
|
|
29
|
+
/** `false` when row was already ingested with the same source, storageProvider, and locator. */
|
|
30
|
+
updated: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function parseFeedOriginal(original: unknown): FeedOriginal | null {
|
|
34
|
+
if (original == null || typeof original !== "object") {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
const record = original as Record<string, unknown>;
|
|
38
|
+
const storageProvider = typeof record.storageProvider === "string" ? record.storageProvider : null;
|
|
39
|
+
const locator = typeof record.locator === "string" ? record.locator : null;
|
|
40
|
+
if (storageProvider == null || locator == null) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
const twinList = Array.isArray(record.twinList)
|
|
44
|
+
? record.twinList.filter((entry): entry is string => typeof entry === "string")
|
|
45
|
+
: undefined;
|
|
46
|
+
return {
|
|
47
|
+
storageProvider,
|
|
48
|
+
locator,
|
|
49
|
+
...(twinList !== undefined ? { twinList } : {}),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function assertFeedLink(target: Target): Link {
|
|
54
|
+
if (target.category !== CategoryLink.LINK) {
|
|
55
|
+
throw new Error(`registerFeed: target is not a link (category=${target.category})`);
|
|
56
|
+
}
|
|
57
|
+
if (!isFeedValue(target.value)) {
|
|
58
|
+
throw new Error(`registerFeed: target value is not a feed loader: ${target.value}`);
|
|
59
|
+
}
|
|
60
|
+
const details = target.details as LinkDetails | undefined;
|
|
61
|
+
if (details == null || details.loaderKey !== target.value) {
|
|
62
|
+
throw new Error("registerFeed: invalid feed link details (loaderKey must equal value)");
|
|
63
|
+
}
|
|
64
|
+
return target as Link;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function hasIngestSourceTag(tagList: string[]): boolean {
|
|
68
|
+
return tagList.some((tag) => tag === ".local" || tag === ".oss");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isSameIngestState(link: Link, source: FeedIngestSource, storageProvider: string, locator: string): boolean {
|
|
72
|
+
if (!link.tagList.includes(source)) {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
const original = parseFeedOriginal(link.details.original);
|
|
76
|
+
if (original == null) {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
return original.storageProvider === storageProvider && original.locator === locator;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Ingest a `.public` feed Link: validate locator, then promote `tagList` source to `.local` or `.oss`
|
|
84
|
+
* and update `details.original.storageProvider` + `locator`.
|
|
85
|
+
*
|
|
86
|
+
* **Node-only** — under `shared/feed/node/`; do not import from the browser entry.
|
|
87
|
+
*/
|
|
88
|
+
export async function registerFeed(input: RegisterFeedInput): Promise<RegisterFeedResult> {
|
|
89
|
+
const id = input.id.trim();
|
|
90
|
+
if (id === "") {
|
|
91
|
+
throw new Error("registerFeed: id is empty");
|
|
92
|
+
}
|
|
93
|
+
if (!isFeedIngestSource(input.source)) {
|
|
94
|
+
throw new Error(`registerFeed: source must be .local or .oss, got ${String(input.source)}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const resolvedStorageProvider = resolveFeedOriginalStorageProvider(input.source, input.storageProvider);
|
|
98
|
+
const locator = input.locator.trim();
|
|
99
|
+
if (locator === "") {
|
|
100
|
+
throw new Error("registerFeed: locator is empty");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const { data: target } = await getTarget({ id });
|
|
104
|
+
if (target == null) {
|
|
105
|
+
throw new Error(`registerFeed: target not found: ${id}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const link = assertFeedLink(target);
|
|
109
|
+
const feedValue = link.value as FeedValue;
|
|
110
|
+
|
|
111
|
+
if (!link.tagList.includes(".public")) {
|
|
112
|
+
if (isSameIngestState(link, input.source, resolvedStorageProvider, locator)) {
|
|
113
|
+
logger.info("feed already ingested", {
|
|
114
|
+
topic: "dedup",
|
|
115
|
+
data: { id, source: input.source, locator },
|
|
116
|
+
});
|
|
117
|
+
return { id, updated: false };
|
|
118
|
+
}
|
|
119
|
+
if (hasIngestSourceTag(link.tagList)) {
|
|
120
|
+
throw new Error("registerFeed: feed already ingested with different source, storageProvider, or locator");
|
|
121
|
+
}
|
|
122
|
+
throw new Error("registerFeed: feed is not .public — cannot ingest");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (input.source === ".local") {
|
|
126
|
+
const localStorageProvider = input.localStorageProvider?.trim() ?? "";
|
|
127
|
+
if (localStorageProvider === "") {
|
|
128
|
+
throw new Error("registerFeed: localStorageProvider is required when source is .local");
|
|
129
|
+
}
|
|
130
|
+
const availability = await checkFeedLocalAvailability({
|
|
131
|
+
value: feedValue,
|
|
132
|
+
storageProvider: resolvedStorageProvider,
|
|
133
|
+
locator,
|
|
134
|
+
localStorageProvider,
|
|
135
|
+
});
|
|
136
|
+
if (!availability.ok) {
|
|
137
|
+
throw new Error(`registerFeed: local locator unavailable — ${availability.reason ?? "unknown"}`);
|
|
138
|
+
}
|
|
139
|
+
} else {
|
|
140
|
+
const availability = await checkFeedOssAvailability(locator);
|
|
141
|
+
if (!availability.ok) {
|
|
142
|
+
throw new Error(`registerFeed: oss locator unavailable — ${availability.reason ?? "unknown"}`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
await updateTarget<Link, LinkDetails>({
|
|
147
|
+
id,
|
|
148
|
+
optimisticLockFilterList: [
|
|
149
|
+
{ field: "category", operator: "eq", value: CategoryLink.LINK },
|
|
150
|
+
{ field: "details->original->>storageProvider", operator: "eq", value: "" },
|
|
151
|
+
],
|
|
152
|
+
updateFn: (row) => {
|
|
153
|
+
if (!row.tagList.includes(".public")) {
|
|
154
|
+
throw new Error("registerFeed: optimistic lock failed — .public tag missing");
|
|
155
|
+
}
|
|
156
|
+
if (hasIngestSourceTag(row.tagList) && !row.tagList.includes(".public")) {
|
|
157
|
+
throw new Error("registerFeed: feed already ingested");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const details = row.details;
|
|
161
|
+
const prior = parseFeedOriginal(details.original);
|
|
162
|
+
const nextOriginal: FeedOriginal = {
|
|
163
|
+
storageProvider: resolvedStorageProvider,
|
|
164
|
+
locator,
|
|
165
|
+
...(prior?.twinList !== undefined ? { twinList: prior.twinList } : {}),
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
tagList: buildFeedTagList(input.source, row.tagList),
|
|
170
|
+
details: {
|
|
171
|
+
...details,
|
|
172
|
+
original: nextOriginal,
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
logger.info("feed ingested", {
|
|
179
|
+
topic: "register",
|
|
180
|
+
data: { id, source: input.source, storageProvider: resolvedStorageProvider, locator },
|
|
181
|
+
});
|
|
182
|
+
return { id, updated: true };
|
|
183
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node-only feed surface — for `src/node.ts` / `target-supabase-sdk/node` only.
|
|
3
|
+
* Never re-export from `../index.ts` or `browser.ts`.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type { FeedIngestSource } from "../feed.interface";
|
|
7
|
+
export { FEED_INGEST_SOURCES, isFeedIngestSource } from "../feed.interface";
|
|
8
|
+
export type { CheckFeedLocalAvailabilityInput, FeedAvailabilityResult } from "./feed.access";
|
|
9
|
+
export { checkFeedLocalAvailability, checkFeedOssAvailability } from "./feed.access";
|
|
10
|
+
export type { RegisterFeedInput, RegisterFeedResult } from "./feed.service";
|
|
11
|
+
export { registerFeed } from "./feed.service";
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# Review Links (`shared/review`)
|
|
2
|
+
|
|
3
|
+
Review is a **convention on `Link`**, not a new Target category (`category` stays `link`).
|
|
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.
|
|
6
|
+
|
|
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
|
+
|
|
9
|
+
```text
|
|
10
|
+
src/shared/review/
|
|
11
|
+
index.ts
|
|
12
|
+
review.interface.ts
|
|
13
|
+
review.build.ts
|
|
14
|
+
review.query.ts
|
|
15
|
+
README.md
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Package entry
|
|
19
|
+
|
|
20
|
+
| Consumer import | What you get |
|
|
21
|
+
|-----------------|--------------|
|
|
22
|
+
| `from "target-supabase-sdk"` | Draft builder, constants, list filters |
|
|
23
|
+
|
|
24
|
+
```typescript
|
|
25
|
+
import {
|
|
26
|
+
buildReviewLinkDraft,
|
|
27
|
+
reviewLinksForTargetFilters,
|
|
28
|
+
} from "target-supabase-sdk";
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Meaning
|
|
34
|
+
|
|
35
|
+
| Concept | Where it lives |
|
|
36
|
+
|---------|----------------|
|
|
37
|
+
| Subject Target | `Link.name` — the associated Target id (`refId` when building the draft) |
|
|
38
|
+
| Interaction body | `Link.details.original` — caller-defined JSON-serializable payload |
|
|
39
|
+
| Review kind | Fixed `Link.value` / `details.loaderKey` = `"review"` |
|
|
40
|
+
| Labels | Optional `tagList` (e.g. interaction type, client, experiment) |
|
|
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.
|
|
43
|
+
|
|
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
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Create input → Link mapping
|
|
49
|
+
|
|
50
|
+
Built via `buildReviewLinkDraft` → `buildLinkTargetDraft`.
|
|
51
|
+
|
|
52
|
+
| Create field | Required | Link field |
|
|
53
|
+
|--------------|----------|------------|
|
|
54
|
+
| `value` | yes | `value` — must be `"review"` |
|
|
55
|
+
| `refId` | yes | `name` — subject Target id |
|
|
56
|
+
| `original` | yes | `details.original` |
|
|
57
|
+
| `tagList` | no | `tagList` (default `[]`) |
|
|
58
|
+
| `description` | no | `details.description` (default `""`) |
|
|
59
|
+
|
|
60
|
+
Fixed at build time (not on create input):
|
|
61
|
+
|
|
62
|
+
| Link field | Value |
|
|
63
|
+
|------------|--------|
|
|
64
|
+
| `category` | `link` |
|
|
65
|
+
| `details.loaderKey` | `"review"` (same as `value`) |
|
|
66
|
+
| `details.manifestVersion` | `0` |
|
|
67
|
+
| `details.preview` | `""` |
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## Example
|
|
72
|
+
|
|
73
|
+
```typescript
|
|
74
|
+
import { buildReviewLinkDraft } from "target-supabase-sdk";
|
|
75
|
+
import { postTarget } from "target-supabase-sdk"; // or your create API
|
|
76
|
+
|
|
77
|
+
const draft = buildReviewLinkDraft({
|
|
78
|
+
value: "review",
|
|
79
|
+
refId: subjectTargetId,
|
|
80
|
+
original: {
|
|
81
|
+
action: "complete",
|
|
82
|
+
progress: 1,
|
|
83
|
+
at: new Date().toISOString(),
|
|
84
|
+
},
|
|
85
|
+
tagList: ["client:web"],
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
await postTarget(draft);
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Every interaction: build a new draft (same `refId` is fine) and insert again.
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## Query
|
|
96
|
+
|
|
97
|
+
List review Links for one subject Target:
|
|
98
|
+
|
|
99
|
+
```typescript
|
|
100
|
+
import { reviewLinksForTargetFilters, scanTargetList } from "target-supabase-sdk";
|
|
101
|
+
|
|
102
|
+
const reviews = await scanTargetList({
|
|
103
|
+
filters: reviewLinksForTargetFilters(subjectTargetId),
|
|
104
|
+
});
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Filters: `category=link`, `value=review`, `name=refId`. Use pagination / `scanTargetList` when history is long.
|
|
108
|
+
|
|
109
|
+
There is **no** SDK helper for dedup or “find existing review” — callers always create new rows unless they add their own policy.
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## Recognizing review Links
|
|
114
|
+
|
|
115
|
+
```typescript
|
|
116
|
+
if (link.category === "link" && link.value === "review") {
|
|
117
|
+
const subjectTargetId = link.name;
|
|
118
|
+
const payload = link.details.original;
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
## Related
|
|
125
|
+
|
|
126
|
+
- `src/link/link.build.ts` — `buildLinkTargetDraft`
|
|
127
|
+
- `src/shared/media/README.md` — same Link-convention pattern for media
|
|
128
|
+
- `src/shared/feed/README.md` — feed Links with richer lifecycle
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { TargetDraft } from "../../core.interface";
|
|
2
|
+
import { buildLinkTargetDraft } from "../../link/link.build";
|
|
3
|
+
import type { Link } from "../../link/link.interface";
|
|
4
|
+
import { isReviewValue, REVIEW_VALUE, type ReviewValue } from "./review.interface";
|
|
5
|
+
|
|
6
|
+
export interface BuildReviewLinkDraftInput {
|
|
7
|
+
/** Must be {@link REVIEW_VALUE}. */
|
|
8
|
+
value: ReviewValue;
|
|
9
|
+
/** Associated Target id — written to `Link.name`. */
|
|
10
|
+
refId: string;
|
|
11
|
+
/** Interaction payload — written to `Link.details.original`. */
|
|
12
|
+
original: unknown;
|
|
13
|
+
tagList?: string[];
|
|
14
|
+
description?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Assemble a review {@link Link} draft (`category=link`, `loaderKey === value === "review"`).
|
|
19
|
+
* Does not validate `refId` existence or write to Supabase.
|
|
20
|
+
*/
|
|
21
|
+
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");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return buildLinkTargetDraft({
|
|
31
|
+
name: refId,
|
|
32
|
+
value: REVIEW_VALUE,
|
|
33
|
+
description: input.description ?? "",
|
|
34
|
+
preview: "",
|
|
35
|
+
loaderKey: REVIEW_VALUE,
|
|
36
|
+
tagList: input.tagList ?? [],
|
|
37
|
+
original: input.original,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Review Link protocol — Link rows with `value` / `loaderKey` === {@link REVIEW_VALUE}.
|
|
3
|
+
* No separate Target category; category remains `link`.
|
|
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.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const REVIEW_VALUE = "review" as const;
|
|
10
|
+
|
|
11
|
+
/** Discriminator stored in both `Link.value` and `details.loaderKey`. */
|
|
12
|
+
export type ReviewValue = typeof REVIEW_VALUE;
|
|
13
|
+
|
|
14
|
+
/** Caller-defined payload stored in `Link.details.original`. */
|
|
15
|
+
export type ReviewOriginal = unknown;
|
|
16
|
+
|
|
17
|
+
export function isReviewValue(value: string): value is ReviewValue {
|
|
18
|
+
return value === REVIEW_VALUE;
|
|
19
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { QueryFilter } from "../../core.interface";
|
|
2
|
+
import { CategoryLink } from "../../link/link.interface";
|
|
3
|
+
import { REVIEW_VALUE } from "./review.interface";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Filters for all review Links whose subject Target is `refId` (`Link.name === refId`).
|
|
7
|
+
* Many rows may match — the SDK does not define dedup for reviews.
|
|
8
|
+
*/
|
|
9
|
+
export function reviewLinksForTargetFilters(refId: string): QueryFilter[] {
|
|
10
|
+
const name = refId.trim();
|
|
11
|
+
return [
|
|
12
|
+
{ field: "category", operator: "eq", value: CategoryLink.LINK },
|
|
13
|
+
{ field: "value", operator: "eq", value: REVIEW_VALUE },
|
|
14
|
+
{ field: "name", operator: "eq", value: name },
|
|
15
|
+
];
|
|
16
|
+
}
|