target-supabase-sdk 0.2.17 → 0.2.19

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 (57) hide show
  1. package/README.md +28 -0
  2. package/dist/browser.d.ts +2 -0
  3. package/dist/browser.d.ts.map +1 -1
  4. package/dist/browser.js +1 -1
  5. package/dist/chunks/{task-queue-DufG7PlX.js → task-queue-CUa40Vb5.js} +117 -2
  6. package/dist/chunks/task-queue-CUa40Vb5.js.map +1 -0
  7. package/dist/node/telegram/api-client.d.ts +12 -0
  8. package/dist/node/telegram/api-client.d.ts.map +1 -0
  9. package/dist/node/telegram/chunk-locator.d.ts +10 -0
  10. package/dist/node/telegram/chunk-locator.d.ts.map +1 -0
  11. package/dist/node/telegram/chunk.d.ts +4 -0
  12. package/dist/node/telegram/chunk.d.ts.map +1 -0
  13. package/dist/node/telegram/index.d.ts +8 -0
  14. package/dist/node/telegram/index.d.ts.map +1 -0
  15. package/dist/node/telegram/storage-provider.d.ts +32 -0
  16. package/dist/node/telegram/storage-provider.d.ts.map +1 -0
  17. package/dist/node/telegram/types.d.ts +53 -0
  18. package/dist/node/telegram/types.d.ts.map +1 -0
  19. package/dist/node.d.ts +4 -0
  20. package/dist/node.d.ts.map +1 -1
  21. package/dist/node.js +538 -7
  22. package/dist/node.js.map +1 -1
  23. package/dist/shared/feed/feed.build.d.ts +33 -0
  24. package/dist/shared/feed/feed.build.d.ts.map +1 -0
  25. package/dist/shared/feed/feed.interface.d.ts +35 -0
  26. package/dist/shared/feed/feed.interface.d.ts.map +1 -0
  27. package/dist/shared/feed/feed.name.d.ts +8 -0
  28. package/dist/shared/feed/feed.name.d.ts.map +1 -0
  29. package/dist/shared/feed/feed.query.d.ts +8 -0
  30. package/dist/shared/feed/feed.query.d.ts.map +1 -0
  31. package/dist/shared/feed/index.d.ts +13 -0
  32. package/dist/shared/feed/index.d.ts.map +1 -0
  33. package/dist/shared/feed/node/feed.access.d.ts +34 -0
  34. package/dist/shared/feed/node/feed.access.d.ts.map +1 -0
  35. package/dist/shared/feed/node/feed.service.d.ts +23 -0
  36. package/dist/shared/feed/node/feed.service.d.ts.map +1 -0
  37. package/dist/shared/feed/node/index.d.ts +11 -0
  38. package/dist/shared/feed/node/index.d.ts.map +1 -0
  39. package/package.json +8 -1
  40. package/src/browser.ts +8 -1
  41. package/src/node/telegram/api-client.ts +146 -0
  42. package/src/node/telegram/chunk-locator.ts +23 -0
  43. package/src/node/telegram/chunk.ts +33 -0
  44. package/src/node/telegram/index.ts +20 -0
  45. package/src/node/telegram/storage-provider.ts +182 -0
  46. package/src/node/telegram/types.ts +82 -0
  47. package/src/node.ts +37 -0
  48. package/src/shared/feed/README.md +163 -0
  49. package/src/shared/feed/feed.build.ts +101 -0
  50. package/src/shared/feed/feed.interface.ts +50 -0
  51. package/src/shared/feed/feed.name.ts +20 -0
  52. package/src/shared/feed/feed.query.ts +13 -0
  53. package/src/shared/feed/index.ts +13 -0
  54. package/src/shared/feed/node/feed.access.ts +134 -0
  55. package/src/shared/feed/node/feed.service.ts +183 -0
  56. package/src/shared/feed/node/index.ts +11 -0
  57. package/dist/chunks/task-queue-DufG7PlX.js.map +0 -1
@@ -0,0 +1,82 @@
1
+ export interface TelegramApiResponse<T> {
2
+ ok: boolean;
3
+ description?: string;
4
+ result?: T;
5
+ }
6
+
7
+ export interface TelegramDocument {
8
+ file_id: string;
9
+ file_unique_id: string;
10
+ file_size?: number;
11
+ }
12
+
13
+ export interface TelegramMessage {
14
+ message_id: number;
15
+ document?: TelegramDocument;
16
+ }
17
+
18
+ export interface TelegramFile {
19
+ file_id: string;
20
+ file_unique_id: string;
21
+ file_size?: number;
22
+ file_path?: string;
23
+ }
24
+
25
+ export interface TelegramUploadResult {
26
+ fileId: string;
27
+ messageId: number;
28
+ }
29
+
30
+ /** Injected config for Telegram Bot API storage — do not read process.env inside SDK. */
31
+ export interface TelegramStorageProviderOptions {
32
+ botToken: string;
33
+ chatId: string;
34
+ /** Default `https://api.telegram.org`. */
35
+ apiBaseUrl?: string;
36
+ /** HTTP(S) proxy for Telegram only (e.g. Clash). Supabase stays direct. */
37
+ proxyUrl?: string;
38
+ /** Default 30s — getMe / getFile. */
39
+ fetchTimeoutMs?: number;
40
+ /** Default 120s — sendDocument / file download. */
41
+ uploadTimeoutMs?: number;
42
+ /** Default 3. */
43
+ uploadRetryAttempts?: number;
44
+ /** Default 2000 — backoff base for attempt N: base * 2^(N-1). */
45
+ uploadRetryBaseMs?: number;
46
+ }
47
+
48
+ export interface ResolvedTelegramStorageOptions {
49
+ botToken: string;
50
+ chatId: string;
51
+ apiBaseUrl: string;
52
+ proxyUrl?: string;
53
+ fetchTimeoutMs: number;
54
+ uploadTimeoutMs: number;
55
+ uploadRetryAttempts: number;
56
+ uploadRetryBaseMs: number;
57
+ }
58
+
59
+ export function resolveTelegramStorageOptions(options: TelegramStorageProviderOptions): ResolvedTelegramStorageOptions {
60
+ const botToken = options.botToken.trim();
61
+ const chatId = options.chatId.trim();
62
+ if (botToken === "") {
63
+ throw new Error("TelegramStorageProviderOptions.botToken is required");
64
+ }
65
+ if (chatId === "") {
66
+ throw new Error("TelegramStorageProviderOptions.chatId is required");
67
+ }
68
+
69
+ const apiBaseUrl = (options.apiBaseUrl?.trim() || "https://api.telegram.org").replace(/\/$/, "");
70
+ const proxyUrl = options.proxyUrl?.trim() || undefined;
71
+
72
+ return {
73
+ botToken,
74
+ chatId,
75
+ apiBaseUrl,
76
+ proxyUrl: proxyUrl !== "" ? proxyUrl : undefined,
77
+ fetchTimeoutMs: options.fetchTimeoutMs ?? 30_000,
78
+ uploadTimeoutMs: options.uploadTimeoutMs ?? 120_000,
79
+ uploadRetryAttempts: options.uploadRetryAttempts ?? 3,
80
+ uploadRetryBaseMs: options.uploadRetryBaseMs ?? 2_000,
81
+ };
82
+ }
package/src/node.ts CHANGED
@@ -134,7 +134,44 @@ export type {
134
134
  SingleProcessServiceOptions,
135
135
  } from "./node/service-host";
136
136
  export { createL3ChildLauncher, createServiceHost, runSingleProcessService } from "./node/service-host";
137
+ export type {
138
+ CallTelegramOptions,
139
+ ResolvedTelegramStorageOptions,
140
+ TelegramApiResponse,
141
+ TelegramChunkDeleteResult,
142
+ TelegramDocument,
143
+ TelegramFile,
144
+ TelegramMessage,
145
+ TelegramStorageProvider,
146
+ TelegramStorageProviderOptions,
147
+ TelegramUploadResult,
148
+ } from "./node/telegram";
149
+ export {
150
+ callTelegramApi,
151
+ createTelegramStorageProvider,
152
+ downloadTelegramChunk,
153
+ encodeTelegramChunkUrl,
154
+ PROVIDER_TELEGRAM,
155
+ parseTelegramChunkUrl,
156
+ resolveTelegramStorageOptions,
157
+ telegramDownloadBinary,
158
+ telegramFileDownloadUrl,
159
+ } from "./node/telegram";
137
160
  export { RepoManager } from "./repo/repo-manager";
161
+ export type {
162
+ CheckFeedLocalAvailabilityInput,
163
+ FeedAvailabilityResult,
164
+ FeedIngestSource,
165
+ RegisterFeedInput,
166
+ RegisterFeedResult,
167
+ } from "./shared/feed/node";
168
+ export {
169
+ checkFeedLocalAvailability,
170
+ checkFeedOssAvailability,
171
+ FEED_INGEST_SOURCES,
172
+ isFeedIngestSource,
173
+ registerFeed,
174
+ } from "./shared/feed/node";
138
175
  export { runCollectLogTick } from "./shared/log/spool/collector";
139
176
  export { LOG_SPOOL_SERVICE_ID_ENV } from "./shared/log/spool/config";
140
177
  export type { LogSpoolCoordinator, LogSpoolCoordinatorOptions } from "./shared/log/spool/coordinator";
@@ -0,0 +1,163 @@
1
+ # Feed Links (`shared/feed`)
2
+
3
+ Feed is a **convention on `Link`**, not a new Target category (`category` stays `link`).
4
+
5
+ ```text
6
+ src/shared/feed/
7
+ index.ts ← browser-safe barrel ONLY (never export ./node)
8
+ feed.interface.ts
9
+ feed.build.ts
10
+ feed.name.ts
11
+ feed.query.ts
12
+ README.md
13
+ node/ ← Node-only (ingest / availability)
14
+ index.ts ← barrel for src/node.ts only
15
+ feed.access.ts
16
+ feed.service.ts
17
+ ```
18
+
19
+ ## Package entry split
20
+
21
+ | Consumer import | What you get | Source |
22
+ |-----------------|--------------|--------|
23
+ | `from "target-supabase-sdk"` | Draft, name, tag merge, dedup filters | `shared/feed/index.ts` → `browser.ts` |
24
+ | `from "target-supabase-sdk/node"` | + `registerFeed`, availability checks | `shared/feed/node/` → `node.ts` |
25
+
26
+ **Do not** re-export `./node` from `index.ts` / `browser.ts`.
27
+
28
+ ```typescript
29
+ // Default / browser — create draft, dedup, recognize feed Links
30
+ import {
31
+ buildFeedLinkDraft,
32
+ buildFeedLinkName,
33
+ feedLinkDedupFilters,
34
+ FEED_VALUES,
35
+ isFeedValue,
36
+ } from "target-supabase-sdk";
37
+
38
+ // Node — ingest after .public draft exists
39
+ import { registerFeed, FEED_INGEST_SOURCES } from "target-supabase-sdk/node";
40
+ ```
41
+
42
+ ---
43
+
44
+ ## Lifecycle
45
+
46
+ 1. **Create (browser or any host)** — `buildFeedLinkDraft` with `source: ".public"`, public `locator` (often a platform URL), `original.storageProvider: ""`. Persist via `postTarget` / `createTarget`.
47
+ 2. **Ingest (Node)** — `registerFeed` on an existing row `id`: validate the new locator, then promote `tagList` from `.public` to `.local` or `.oss` and update `original.storageProvider` + `locator`. Preserves `twinList` and other non-source tags.
48
+
49
+ ---
50
+
51
+ ## Link field rules
52
+
53
+ | Field | Rule |
54
+ |-------|------|
55
+ | `value` | `feed.image` \| `feed.audio` \| `feed.video` \| `feed.image-list` |
56
+ | `details.loaderKey` | Always equals `value` |
57
+ | `name` | `{platformName}.{feedKey}` via `buildFeedLinkName` |
58
+ | Dedup | `category=link` + `value` + `name` (`feedLinkDedupFilters`) |
59
+ | `tagList` | Exactly one feed **system** source tag (`.public` \| `.local` \| `.oss`) |
60
+ | `description` / `preview` | Optional; default `""` |
61
+
62
+ ### Naming
63
+
64
+ - `sanitizeFeedPlatformName` replaces `.` in `platformName` with spaces before ``${platform}.${feedKey}``.
65
+
66
+ ---
67
+
68
+ ## `details.original`
69
+
70
+ | Key | `.public` draft | After ingest |
71
+ |-----|-----------------|--------------|
72
+ | `storageProvider` | `""` | Backend id (required for `.local` / `.oss`) |
73
+ | `locator` | Public / platform locator | Validated path (`.local`) or URL (`.oss`) |
74
+ | `twinList` | Optional sibling locators | Unchanged by `registerFeed` |
75
+
76
+ ---
77
+
78
+ ## System vs user tags
79
+
80
+ Leading `.` marks **system** tags (not user tags).
81
+
82
+ | Tag | Meaning |
83
+ |-----|---------|
84
+ | `.public` | Public-network link; ingest when stable copy exists |
85
+ | `.local` | In Target on this device (`LOCAL_STORAGE_PROVIDER` + absolute file/dir path) |
86
+ | `.oss` | In Target on shared storage (HTTP(S) locator must respond) |
87
+
88
+ `buildFeedTagList` strips `.public` / `.local` / `.oss` from user input and appends the chosen `source` once.
89
+
90
+ ---
91
+
92
+ ## Register flow (`registerFeed`)
93
+
94
+ **Input**
95
+
96
+ | Field | Rule |
97
+ |-------|------|
98
+ | `id` | Existing feed Link row |
99
+ | `source` | `.local` or `.oss` only |
100
+ | `storageProvider` | Non-empty (validated by `resolveFeedOriginalStorageProvider`) |
101
+ | `locator` | New canonical locator after ingest |
102
+ | `localStorageProvider` | **Required** when `source` is `.local` (typically `process.env.LOCAL_STORAGE_PROVIDER`). Ignored for `.oss`. |
103
+
104
+ **Steps**
105
+
106
+ 1. Load target by `id`; must be `category=link` with `isFeedValue(value)` and `loaderKey === value`.
107
+ 2. **Idempotent** — if `tagList` already has the target `source` and `original` matches `storageProvider` + `locator` → `{ id, updated: false }`.
108
+ 3. Require `tagList` to include `.public` (not already ingested with different state).
109
+ 4. **Availability**
110
+ - `.local` — `checkFeedLocalAvailability`: `storageProvider === localStorageProvider`, absolute path, file (or directory for `feed.image-list`) exists and is non-empty where applicable.
111
+ - `.oss` — `checkFeedOssAvailability`: `http(s)` URL; `HEAD`, then `GET` with `Range: bytes=0-0` if `HEAD` is not supported.
112
+ 5. **Update** — `updateTarget` with optimistic lock `details->original->>storageProvider` eq `""` (still `.public` tier). Sets `tagList` via `buildFeedTagList`, updates `original.storageProvider` and `locator`, keeps `twinList`.
113
+
114
+ **Errors (representative)**
115
+
116
+ | Condition | Result |
117
+ |-----------|--------|
118
+ | Not a feed Link | throw |
119
+ | Already `.local`/`.oss` with different locator | throw |
120
+ | Missing `.public` tag | throw |
121
+ | Locator check fails | throw |
122
+ | Concurrent ingest (storageProvider already set) | optimistic lock failure |
123
+
124
+ ```typescript
125
+ import { registerFeed } from "target-supabase-sdk/node";
126
+
127
+ const { id, updated } = await registerFeed({
128
+ id: feedLinkId,
129
+ source: ".local",
130
+ storageProvider: process.env.LOCAL_STORAGE_PROVIDER!,
131
+ locator: "D:\\\\data\\\\feeds\\\\bilibili.12345.mp4",
132
+ localStorageProvider: process.env.LOCAL_STORAGE_PROVIDER!,
133
+ });
134
+ ```
135
+
136
+ ---
137
+
138
+ ## Env (Node hosts)
139
+
140
+ | Variable | Role |
141
+ |----------|------|
142
+ | `LOCAL_STORAGE_PROVIDER` | This machine’s storage id; pass as both `storageProvider` and `localStorageProvider` for `.local` ingest on this host |
143
+
144
+ Same variable as media register — see `src/shared/media/README.md`.
145
+
146
+ ---
147
+
148
+ ## Design notes / limitations
149
+
150
+ | Topic | Current behavior | Possible follow-up |
151
+ |-------|------------------|-------------------|
152
+ | OSS auth | Only anonymous `HEAD`/`GET` | Signed URLs or provider-specific probes |
153
+ | `.local` on another host | Fails availability (by design, like media) | Remote agent ingest |
154
+ | `twinList` on ingest | Not re-validated | Optional probe per twin |
155
+ | Re-ingest / migrate `.local` → `.oss` | Not supported | New API with lock on prior source tag |
156
+
157
+ ---
158
+
159
+ ## Related
160
+
161
+ - `.cursor/skills/feed-link/SKILL.md`
162
+ - Media register pattern: `src/shared/media/README.md`
163
+ - Optimistic lock: `.cursor/skills/optimistic-lock-update/SKILL.md`
@@ -0,0 +1,101 @@
1
+ import type { TargetDraft } from "../../core.interface";
2
+ import { buildLinkTargetDraft } from "../../link/link.build";
3
+ import type { Link } from "../../link/link.interface";
4
+ import {
5
+ FEED_SOURCE_TAGS,
6
+ type FeedOriginal,
7
+ type FeedSource,
8
+ type FeedValue,
9
+ isFeedSource,
10
+ isFeedValue,
11
+ } from "./feed.interface";
12
+ import { buildFeedLinkName } from "./feed.name";
13
+
14
+ export interface BuildFeedLinkDraftInput {
15
+ value: FeedValue;
16
+ /** Dots are normalized to spaces in {@link buildFeedLinkName} before joining with `feedKey`. */
17
+ platformName: string;
18
+ feedKey: string;
19
+ source: FeedSource;
20
+ locator: string;
21
+ /**
22
+ * Written to `original.storageProvider`. Must be empty when `source` is `.public`;
23
+ * required when `source` is `.local` or `.oss`.
24
+ */
25
+ storageProvider?: string;
26
+ description?: string;
27
+ preview?: string;
28
+ /** Locators for related resources of the same feed; stored on `original.twinList`. */
29
+ twinList?: string[];
30
+ tagList?: string[];
31
+ }
32
+
33
+ /** Trim entries and drop empties; `undefined` when input omitted. */
34
+ export function normalizeFeedTwinList(twinList?: string[]): string[] | undefined {
35
+ if (twinList === undefined) {
36
+ return undefined;
37
+ }
38
+ return twinList.map((entry) => entry.trim()).filter((entry) => entry !== "");
39
+ }
40
+
41
+ /** `original.storageProvider` from optional input and required `source` tier. */
42
+ export function resolveFeedOriginalStorageProvider(source: FeedSource, storageProvider?: string): string {
43
+ const trimmed = storageProvider?.trim() ?? "";
44
+ if (source === ".public") {
45
+ if (trimmed !== "") {
46
+ throw new Error("resolveFeedOriginalStorageProvider: storageProvider must be empty when source is .public");
47
+ }
48
+ return "";
49
+ }
50
+ if (trimmed === "") {
51
+ throw new Error(
52
+ "resolveFeedOriginalStorageProvider: storageProvider is required when source is .local or .oss",
53
+ );
54
+ }
55
+ return trimmed;
56
+ }
57
+
58
+ /** Merge user tags with required `source`; strip any other feed source tags from user input. */
59
+ export function buildFeedTagList(source: FeedSource, tagList?: string[]): string[] {
60
+ const sourceSet = new Set<string>(FEED_SOURCE_TAGS);
61
+ const user = tagList ?? [];
62
+ const filtered = user.filter((tag) => !sourceSet.has(tag));
63
+ return [...filtered, source];
64
+ }
65
+
66
+ /**
67
+ * Assemble a feed {@link Link} draft (`category=link`, `loaderKey === value`).
68
+ * Does not validate locator reachability or write to Supabase.
69
+ */
70
+ export function buildFeedLinkDraft(input: BuildFeedLinkDraftInput): TargetDraft<Link> {
71
+ if (!isFeedValue(input.value)) {
72
+ throw new Error(`buildFeedLinkDraft: unsupported feed value: ${String(input.value)}`);
73
+ }
74
+ if (!isFeedSource(input.source)) {
75
+ throw new Error(`buildFeedLinkDraft: unsupported feed source: ${String(input.source)}`);
76
+ }
77
+
78
+ const name = buildFeedLinkName(input.platformName, input.feedKey);
79
+ const locator = input.locator.trim();
80
+ if (locator === "") {
81
+ throw new Error("buildFeedLinkDraft: locator is empty");
82
+ }
83
+
84
+ const twinList = normalizeFeedTwinList(input.twinList);
85
+ const storageProvider = resolveFeedOriginalStorageProvider(input.source, input.storageProvider);
86
+ const original: FeedOriginal = {
87
+ storageProvider,
88
+ locator,
89
+ ...(twinList !== undefined ? { twinList } : {}),
90
+ };
91
+
92
+ return buildLinkTargetDraft({
93
+ name,
94
+ value: input.value,
95
+ description: input.description ?? "",
96
+ preview: input.preview ?? "",
97
+ loaderKey: input.value,
98
+ tagList: buildFeedTagList(input.source, input.tagList),
99
+ original,
100
+ });
101
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Feed Link protocol — Link rows with value/loaderKey in {@link FEED_VALUES}.
3
+ * No separate Target category; category remains `link`.
4
+ */
5
+
6
+ export const FEED_VALUES = ["feed.image", "feed.audio", "feed.video", "feed.image-list"] as const;
7
+
8
+ /** Discriminator stored in both `Link.value` and `details.loaderKey`. */
9
+ export type FeedValue = (typeof FEED_VALUES)[number];
10
+
11
+ /**
12
+ * Feed lifecycle tier — exactly one must appear in `tagList` (via `buildFeedTagList`).
13
+ *
14
+ * Leading `.` marks **system** tags (distinct from user-defined `tagList` entries).
15
+ */
16
+ export const FEED_SOURCE_TAGS = [".public", ".local", ".oss"] as const;
17
+
18
+ export type FeedSource = (typeof FEED_SOURCE_TAGS)[number];
19
+
20
+ /** `registerFeed` only transitions `.public` → `.local` or `.oss`. */
21
+ export const FEED_INGEST_SOURCES = [".local", ".oss"] as const;
22
+
23
+ export type FeedIngestSource = (typeof FEED_INGEST_SOURCES)[number];
24
+
25
+ export function isFeedIngestSource(value: string): value is FeedIngestSource {
26
+ return (FEED_INGEST_SOURCES as readonly string[]).includes(value);
27
+ }
28
+
29
+ /** `Link.details.original` for feed Links. */
30
+ export interface FeedOriginal {
31
+ /**
32
+ * Backend / host id when {@link FeedSource} is `.local` or `.oss` (e.g. `LOCAL_STORAGE_PROVIDER`).
33
+ * Empty string when source is `.public`.
34
+ */
35
+ storageProvider: string;
36
+ locator: string;
37
+ /**
38
+ * Locators for sibling resources of the same feed (e.g. other media types).
39
+ * Omitted when not supplied on create.
40
+ */
41
+ twinList?: string[];
42
+ }
43
+
44
+ export function isFeedValue(value: string): value is FeedValue {
45
+ return (FEED_VALUES as readonly string[]).includes(value);
46
+ }
47
+
48
+ export function isFeedSource(value: string): value is FeedSource {
49
+ return (FEED_SOURCE_TAGS as readonly string[]).includes(value);
50
+ }
@@ -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
+ }