target-supabase-sdk 0.2.15 → 0.2.17

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 (99) hide show
  1. package/README.md +20 -0
  2. package/dist/browser.d.ts +6 -2
  3. package/dist/browser.d.ts.map +1 -1
  4. package/dist/browser.js +1 -1
  5. package/dist/chunks/{task-queue-DCY_0Qj0.js → task-queue-DufG7PlX.js} +295 -121
  6. package/dist/chunks/task-queue-DufG7PlX.js.map +1 -0
  7. package/dist/config/config.api.d.ts +61 -0
  8. package/dist/config/config.api.d.ts.map +1 -0
  9. package/dist/{service → config}/config.interface.d.ts +1 -3
  10. package/dist/config/config.interface.d.ts.map +1 -0
  11. package/dist/config/index.d.ts +9 -0
  12. package/dist/config/index.d.ts.map +1 -0
  13. package/dist/node/runtime-state/service-runtime-state.types.d.ts +1 -1
  14. package/dist/node/runtime-state/service-runtime-state.types.d.ts.map +1 -1
  15. package/dist/node/service-guard/registry-slot-guard-step.d.ts +1 -1
  16. package/dist/node/service-guard/registry-slot-guard-step.d.ts.map +1 -1
  17. package/dist/node/service-guard/service-guard.interface.d.ts +1 -1
  18. package/dist/node/service-guard/service-guard.interface.d.ts.map +1 -1
  19. package/dist/node/service-host/claim-registry-slot.d.ts +1 -1
  20. package/dist/node/service-host/claim-registry-slot.d.ts.map +1 -1
  21. package/dist/node/service-host/service-host.d.ts +1 -1
  22. package/dist/node/service-host/service-host.d.ts.map +1 -1
  23. package/dist/node/service-host/single-process-service.d.ts +1 -1
  24. package/dist/node/service-host/single-process-service.d.ts.map +1 -1
  25. package/dist/node.d.ts +2 -0
  26. package/dist/node.d.ts.map +1 -1
  27. package/dist/node.js +211 -6
  28. package/dist/node.js.map +1 -1
  29. package/dist/service/index.d.ts +5 -10
  30. package/dist/service/index.d.ts.map +1 -1
  31. package/dist/service/registry/index.d.ts +14 -0
  32. package/dist/service/registry/index.d.ts.map +1 -0
  33. package/dist/service/{registry-lifecycle.d.ts → registry/registry-lifecycle.d.ts} +1 -1
  34. package/dist/service/registry/registry-lifecycle.d.ts.map +1 -0
  35. package/dist/service/{config.api.d.ts → registry/registry.api.d.ts} +5 -65
  36. package/dist/service/registry/registry.api.d.ts.map +1 -0
  37. package/dist/service/registry/registry.constant.d.ts +23 -0
  38. package/dist/service/registry/registry.constant.d.ts.map +1 -0
  39. package/dist/service/registry/registry.interface.d.ts +15 -0
  40. package/dist/service/registry/registry.interface.d.ts.map +1 -0
  41. package/dist/service/{registry.service.d.ts → registry/registry.service.d.ts} +23 -15
  42. package/dist/service/registry/registry.service.d.ts.map +1 -0
  43. package/dist/service/service.interface.d.ts +0 -10
  44. package/dist/service/service.interface.d.ts.map +1 -1
  45. package/dist/shared/media/index.d.ts +14 -0
  46. package/dist/shared/media/index.d.ts.map +1 -0
  47. package/dist/shared/media/media.build.d.ts +20 -0
  48. package/dist/shared/media/media.build.d.ts.map +1 -0
  49. package/dist/shared/media/media.interface.d.ts +38 -0
  50. package/dist/shared/media/media.interface.d.ts.map +1 -0
  51. package/dist/shared/media/media.mime.d.ts +4 -0
  52. package/dist/shared/media/media.mime.d.ts.map +1 -0
  53. package/dist/shared/media/media.name.d.ts +6 -0
  54. package/dist/shared/media/media.name.d.ts.map +1 -0
  55. package/dist/shared/media/media.query.d.ts +8 -0
  56. package/dist/shared/media/media.query.d.ts.map +1 -0
  57. package/dist/shared/media/node/index.d.ts +9 -0
  58. package/dist/shared/media/node/index.d.ts.map +1 -0
  59. package/dist/shared/media/node/media.access.d.ts +40 -0
  60. package/dist/shared/media/node/media.access.d.ts.map +1 -0
  61. package/dist/shared/media/node/media.service.d.ts +30 -0
  62. package/dist/shared/media/node/media.service.d.ts.map +1 -0
  63. package/package.json +1 -1
  64. package/src/browser.ts +21 -9
  65. package/src/config/config.api.ts +72 -0
  66. package/src/{service → config}/config.interface.ts +1 -4
  67. package/src/config/index.ts +9 -0
  68. package/src/node/runtime-state/create-service-runtime-state-store.ts +1 -1
  69. package/src/node/runtime-state/service-runtime-state.types.ts +1 -1
  70. package/src/node/service-guard/registry-slot-guard-step.ts +4 -1
  71. package/src/node/service-guard/run-service-guard-tick.ts +1 -1
  72. package/src/node/service-guard/service-guard.interface.ts +1 -1
  73. package/src/node/service-host/claim-registry-slot.ts +2 -2
  74. package/src/node/service-host/service-host.ts +2 -2
  75. package/src/node/service-host/single-process-service.ts +1 -1
  76. package/src/node.ts +13 -0
  77. package/src/service/index.ts +30 -40
  78. package/src/service/registry/index.ts +65 -0
  79. package/src/service/{registry-lifecycle.ts → registry/registry-lifecycle.ts} +3 -3
  80. package/src/service/{config.api.ts → registry/registry.api.ts} +6 -80
  81. package/src/service/registry/registry.constant.ts +17 -0
  82. package/src/service/registry/registry.interface.ts +16 -0
  83. package/src/service/{registry.service.ts → registry/registry.service.ts} +66 -65
  84. package/src/service/service.interface.ts +0 -12
  85. package/src/shared/media/README.md +109 -0
  86. package/src/shared/media/index.ts +19 -0
  87. package/src/shared/media/media.build.ts +69 -0
  88. package/src/shared/media/media.interface.ts +45 -0
  89. package/src/shared/media/media.mime.ts +64 -0
  90. package/src/shared/media/media.name.ts +33 -0
  91. package/src/shared/media/media.query.ts +13 -0
  92. package/src/shared/media/node/index.ts +13 -0
  93. package/src/shared/media/node/media.access.ts +148 -0
  94. package/src/shared/media/node/media.service.ts +129 -0
  95. package/dist/chunks/task-queue-DCY_0Qj0.js.map +0 -1
  96. package/dist/service/config.api.d.ts.map +0 -1
  97. package/dist/service/config.interface.d.ts.map +0 -1
  98. package/dist/service/registry-lifecycle.d.ts.map +0 -1
  99. package/dist/service/registry.service.d.ts.map +0 -1
@@ -9,21 +9,17 @@
9
9
  * for crashed / ungraceful exits (stale `Service.details.runtime`)
10
10
  */
11
11
 
12
- import { isOptimisticLockError, updateTargetDetails } from "../core.api";
13
- import type { Node } from "../node/node.interface";
14
- import { createLogger } from "../shared/log";
15
- import { getConfig } from "./config.api";
16
- import { type Config, type ConfigDetails, TARGET_SYSTEM_REGISTRY_KEY } from "./config.interface";
17
- import { getService } from "./service.api";
18
- import {
19
- type Service,
20
- type ServiceDetails,
21
- type ServiceNodeSnapshot,
22
- type ServiceSlot,
23
- ServiceSlotStatus,
24
- } from "./service.interface";
25
-
26
- export { TARGET_SYSTEM_REGISTRY_KEY } from "./config.interface";
12
+ import { getConfig } from "../../config/config.api";
13
+ import type { Config, ConfigDetails } from "../../config/config.interface";
14
+ import { isOptimisticLockError, updateTargetDetails } from "../../core.api";
15
+ import type { Node } from "../../node/node.interface";
16
+ import { createLogger } from "../../shared/log";
17
+ import { getService } from "../service.api";
18
+ import type { Service, ServiceDetails, ServiceNodeSnapshot } from "../service.interface";
19
+ import { TARGET_SYSTEM_REGISTRY_KEY } from "./registry.constant";
20
+ import { type ServiceSlot, ServiceSlotStatus } from "./registry.interface";
21
+
22
+ export { TARGET_SYSTEM_REGISTRY_KEY } from "./registry.constant";
27
23
 
28
24
  /** `details.meta.revision` on the system registry Config — optimistic-lock token. */
29
25
  interface RegistryConfigMeta {
@@ -85,19 +81,24 @@ export interface ReleaseSystemRegistrySlotsByServiceIdOutcome {
85
81
  unchangedServiceIds: string[];
86
82
  }
87
83
 
88
- export interface AppendSystemRegistrySlotsInput {
89
- /** Logical service keys to declare with one new EMPTY slot each when missing. */
90
- serviceValues: string[];
84
+ export interface AppendSystemRegistryEmptySlotsInput {
85
+ /** Logical service key (e.g. `download-service`). */
86
+ serviceValue: string;
87
+ /** How many EMPTY slots to append. Default `1`. */
88
+ count?: number;
91
89
  traceId?: string;
92
90
  maxAttempts?: number;
93
91
  }
94
92
 
95
- export interface AppendSystemRegistrySlotsOutcome {
93
+ export interface AppendSystemRegistryEmptySlotsOutcome {
96
94
  config: Config;
97
- /** Service keys that received a new EMPTY slot this run. */
98
- added: string[];
99
- /** Requested keys that already had at least one declared slot — no change. */
100
- skipped: string[];
95
+ serviceValue: string;
96
+ /** EMPTY slots appended this run. */
97
+ appended: number;
98
+ /** Declared slot count for `serviceValue` before this run. */
99
+ capacityBefore: number;
100
+ /** Declared slot count for `serviceValue` after this run. */
101
+ capacityAfter: number;
101
102
  }
102
103
 
103
104
  export class ServiceRegistryError extends Error {
@@ -618,30 +619,35 @@ export async function releaseSystemRegistrySlotsByServiceId(
618
619
  }
619
620
 
620
621
  /**
621
- * Append EMPTY {@link ServiceSlot} rows for logical service keys not yet declared.
622
- * Idempotent — skips keys that already appear in `details.objects`.
622
+ * Append one or more EMPTY {@link ServiceSlot} rows for a given `serviceValue`.
623
+ *
624
+ * Always grows capacity (does not skip when the key is already declared).
625
+ * Use for replica scale-out (e.g. second `download-service` instance).
623
626
  * Does not modify existing slots or release ACTIVE bindings.
624
627
  */
625
- export async function appendSystemRegistrySlots(
626
- input: AppendSystemRegistrySlotsInput,
627
- ): Promise<AppendSystemRegistrySlotsOutcome> {
628
- const serviceValues = [
629
- ...new Set(input.serviceValues.map((value) => value.trim()).filter((value) => value !== "")),
630
- ];
631
- if (serviceValues.length === 0) {
632
- throw new Error("[appendSystemRegistrySlots] serviceValues must not be empty");
628
+ export async function appendSystemRegistryEmptySlots(
629
+ input: AppendSystemRegistryEmptySlotsInput,
630
+ ): Promise<AppendSystemRegistryEmptySlotsOutcome> {
631
+ const serviceValue = input.serviceValue.trim();
632
+ if (serviceValue === "") {
633
+ throw new Error("[appendSystemRegistryEmptySlots] serviceValue must not be empty");
634
+ }
635
+
636
+ const count = input.count ?? 1;
637
+ if (!Number.isInteger(count) || count < 1) {
638
+ throw new Error("[appendSystemRegistryEmptySlots] count must be an integer >= 1");
633
639
  }
634
640
 
635
641
  const maxAttempts = input.maxAttempts ?? DEFAULT_REGISTER_RETRY_ATTEMPTS;
636
642
  const logger = createLogger({
637
- module: "appendSystemRegistrySlots",
643
+ module: "appendSystemRegistryEmptySlots",
638
644
  traceId: input.traceId,
639
- labels: { serviceValues: serviceValues.join(",") },
645
+ labels: { serviceValue, count: String(count) },
640
646
  });
641
647
 
642
- logger.info("开始追加 registry 槽位声明", {
648
+ logger.info("开始追加 EMPTY registry 槽位", {
643
649
  topic: LOG_TOPIC_REGISTRY,
644
- data: { serviceValues },
650
+ data: { serviceValue, count },
645
651
  });
646
652
 
647
653
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
@@ -649,31 +655,16 @@ export async function appendSystemRegistrySlots(
649
655
  const config = await loadSystemRegistryConfig();
650
656
  const revision = parseRegistryRevision(config.details.meta);
651
657
  const slots = parseServiceSlots(config);
658
+ const capacityBefore = slots.filter((slot) => slot.serviceValue === serviceValue).length;
652
659
 
653
- const added: string[] = [];
654
- const skipped: string[] = [];
655
- const nextSlots = [...slots];
656
-
657
- for (const serviceValue of serviceValues) {
658
- if (isServiceValueDeclared(nextSlots, serviceValue)) {
659
- skipped.push(serviceValue);
660
- continue;
661
- }
662
- nextSlots.push({
660
+ const nextSlots = [
661
+ ...slots,
662
+ ...Array.from({ length: count }, () => ({
663
663
  serviceValue,
664
664
  serviceId: null,
665
665
  status: ServiceSlotStatus.EMPTY,
666
- });
667
- added.push(serviceValue);
668
- }
669
-
670
- if (added.length === 0) {
671
- logger.info("所有 service 已声明 — 无需追加", {
672
- topic: LOG_TOPIC_REGISTRY,
673
- data: { serviceValues, skipped },
674
- });
675
- return { config, added, skipped: serviceValues };
676
- }
666
+ })),
667
+ ];
677
668
 
678
669
  await persistSlotClaim({
679
670
  configId: config.id,
@@ -682,33 +673,43 @@ export async function appendSystemRegistrySlots(
682
673
  });
683
674
 
684
675
  const updated = await loadSystemRegistryConfig();
685
- logger.info("registry 槽位追加成功", {
676
+ const capacityAfter = parseServiceSlots(updated).filter(
677
+ (slot) => slot.serviceValue === serviceValue,
678
+ ).length;
679
+
680
+ logger.info("EMPTY registry 槽位追加成功", {
686
681
  topic: LOG_TOPIC_REGISTRY,
687
- data: { added, skipped, attempt, revision: revision + 1 },
682
+ data: { serviceValue, appended: count, capacityBefore, capacityAfter, attempt },
688
683
  });
689
684
 
690
- return { config: updated, added, skipped };
685
+ return {
686
+ config: updated,
687
+ serviceValue,
688
+ appended: count,
689
+ capacityBefore,
690
+ capacityAfter,
691
+ };
691
692
  } catch (error) {
692
693
  if (isOptimisticLockError(error) && attempt < maxAttempts) {
693
694
  logger.warn("registry 乐观锁冲突,重试", {
694
695
  topic: LOG_TOPIC_REGISTRY,
695
- data: { serviceValues, attempt, maxAttempts },
696
+ data: { serviceValue, count, attempt, maxAttempts },
696
697
  });
697
698
  await sleep(50 * attempt);
698
699
  continue;
699
700
  }
700
701
 
701
702
  const message = error instanceof Error ? error.message : String(error);
702
- logger.error("registry 槽位追加失败", {
703
+ logger.error("EMPTY registry 槽位追加失败", {
703
704
  topic: LOG_TOPIC_REGISTRY,
704
- data: { serviceValues, attempt, message },
705
+ data: { serviceValue, count, attempt, message },
705
706
  });
706
707
  throw new ServiceRegistryError(message, "REGISTRY_UPDATE_FAILED");
707
708
  }
708
709
  }
709
710
 
710
711
  throw new ServiceRegistryError(
711
- "[appendSystemRegistrySlots] Optimistic lock retries exhausted",
712
+ "[appendSystemRegistryEmptySlots] Optimistic lock retries exhausted",
712
713
  "REGISTRY_UPDATE_FAILED",
713
714
  );
714
715
  }
@@ -45,18 +45,6 @@ export interface ServiceRuntime {
45
45
  nodes: ServiceNodeSnapshot[];
46
46
  }
47
47
 
48
- export enum ServiceSlotStatus {
49
- EMPTY = "EMPTY",
50
- ACTIVE = "ACTIVE",
51
- }
52
-
53
- export interface ServiceSlot {
54
- serviceValue: string;
55
- /** Bound {@link Service.id} when {@link ServiceSlotStatus.ACTIVE}; null when EMPTY. */
56
- serviceId: string | null;
57
- status: ServiceSlotStatus;
58
- }
59
-
60
48
  export interface Api extends Target {
61
49
  /** "core.post.target.0" 唯一键 领域 + METHOD + content + VERSION */
62
50
  value: string;
@@ -0,0 +1,109 @@
1
+ # Media Links (`shared/media`)
2
+
3
+ Media is a **convention on `Link`**, not a new Target category (`category` stays `link`).
4
+
5
+ All media code lives under **`src/shared/media/`**. Node-only modules are kept in the **`node/`** subdirectory so the filesystem path matches the package entry split.
6
+
7
+ ## Package entry split (important)
8
+
9
+ | Consumer import | What you get | Source |
10
+ |-----------------|--------------|--------|
11
+ | `from "target-supabase-sdk"` | Browser-safe helpers only | `shared/media/index.ts` → `browser.ts` |
12
+ | `from "target-supabase-sdk/node"` | + register / availability / probe | `shared/media/node/` → `node.ts` |
13
+
14
+ There is **no** public subpath like `target-supabase-sdk/shared/media/node` — consumers always use the package `/node` entry.
15
+
16
+ **Do not** re-export anything from `node/` in `index.ts` / `browser.ts`. Those files import `node:fs` / `node:crypto` / `node:path`. Pulling them into the default entry breaks Chrome/Webpack bundles (`verify-browser-entry` will fail).
17
+
18
+ ```text
19
+ src/shared/media/
20
+ index.ts ← browser-safe barrel ONLY (never export ./node)
21
+ media.interface.ts
22
+ media.build.ts
23
+ media.mime.ts
24
+ media.name.ts
25
+ media.query.ts
26
+ README.md
27
+ node/ ← Node-only (fs / register)
28
+ index.ts ← barrel for src/node.ts only
29
+ media.access.ts
30
+ media.service.ts
31
+ ```
32
+
33
+ ### Correct consumer imports
34
+
35
+ ```typescript
36
+ // Default / browser — draft, mime, name, filters
37
+ import {
38
+ buildMediaLinkDraft,
39
+ buildMediaNameFromLocator,
40
+ MEDIA_VALUES,
41
+ mediaLinkDedupFilters,
42
+ } from "target-supabase-sdk";
43
+
44
+ // Node workers / CLI — dedup + availability + register
45
+ import {
46
+ findMediaLink,
47
+ checkMediaAvailability,
48
+ registerMediaLink,
49
+ } from "target-supabase-sdk/node";
50
+ ```
51
+
52
+ ### Incorrect
53
+
54
+ ```typescript
55
+ // BAD: Node-only APIs are not on the default entry
56
+ import { registerMediaLink } from "target-supabase-sdk";
57
+ ```
58
+
59
+ Internal SDK code:
60
+
61
+ - Browser-safe leaves: `from "../media.build"` (etc.)
62
+ - Node leaves: `from "./media.access"` inside `node/`, or `from "../shared/media/node"` from `src/node.ts`
63
+ - Never pull `./node` through the parent `index.ts`
64
+
65
+ ---
66
+
67
+ ## Link field rules
68
+
69
+ | Field | Rule |
70
+ |-------|------|
71
+ | `value` | `video` \| `audio` \| `image` \| `image-list` |
72
+ | `details.loaderKey` | Always equals `value` |
73
+ | `name` | Caller-provided unique key (or `buildMediaNameFromLocator`) |
74
+ | `tagList` / `description` | Caller-defined (e.g. producer / scene tags) |
75
+ | `details.manifestVersion` | `0` (via `buildLinkTargetDraft`) |
76
+ | `details.preview` | `""` |
77
+
78
+ ## `details.original`
79
+
80
+ | Key | Single file (`video`/`audio`/`image`) | `image-list` |
81
+ |-----|----------------------------------------|--------------|
82
+ | `storageProvider` | Host / backend id (e.g. `windows-storage:HOSTNAME`) | same |
83
+ | `locator` | Absolute file path | Absolute **directory** path |
84
+ | `contentHash` | SHA-256 hex (`MEDIA_CONTENT_HASH_ALGORITHM`) | `""` |
85
+ | `size` | Byte length | File count (immediate children only, non-recursive) |
86
+ | `mimeType` | e.g. `video/mp4` | `""` |
87
+
88
+ `image-list` rules:
89
+
90
+ 1. `locator` must be an **absolute** directory path.
91
+ 2. Only **one level** under that directory is considered (no recursion).
92
+ 3. `size` counts **files** in that level (directories among children are ignored).
93
+
94
+ ## Register flow (`registerMediaLink`)
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).
100
+
101
+ ## Env (Node hosts)
102
+
103
+ | Variable | Role |
104
+ |----------|------|
105
+ | `LOCAL_STORAGE_PROVIDER` | This machine’s storage id; pass as `localStorageProvider` into availability / register |
106
+
107
+ Cross-host `storageProvider` cannot be verified on the current process — availability returns `ok: false`.
108
+
109
+ See `.cursor/skills/media-link/SKILL.md`.
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Media Link — browser-safe barrel only.
3
+ *
4
+ * Node-only code lives in `./node/` and is exported from `src/node.ts`,
5
+ * never from this file (see README.md).
6
+ */
7
+
8
+ export type { BuildMediaLinkDraftInput } from "./media.build";
9
+ export { buildMediaLinkDraft } from "./media.build";
10
+ export type { MediaOriginal, MediaValue } from "./media.interface";
11
+ export {
12
+ isMediaValue,
13
+ MEDIA_CONTENT_HASH_ALGORITHM,
14
+ MEDIA_CONTENT_HASH_PREFIX_LENGTH,
15
+ MEDIA_VALUES,
16
+ } from "./media.interface";
17
+ export { guessMediaMime } from "./media.mime";
18
+ export { buildMediaNameFromLocator } from "./media.name";
19
+ export { mediaLinkDedupFilters } from "./media.query";
@@ -0,0 +1,69 @@
1
+ import type { TargetDraft } from "../../core.interface";
2
+ import { buildLinkTargetDraft } from "../../link/link.build";
3
+ import type { Link } from "../../link/link.interface";
4
+ import { isMediaValue, type MediaOriginal, type MediaValue } from "./media.interface";
5
+
6
+ export interface BuildMediaLinkDraftInput {
7
+ value: MediaValue;
8
+ name: string;
9
+ storageProvider: string;
10
+ locator: string;
11
+ contentHash: string;
12
+ size: number;
13
+ mimeType: string;
14
+ description?: string;
15
+ tagList?: string[];
16
+ }
17
+
18
+ /**
19
+ * Assemble a media {@link Link} draft (`category=link`, `loaderKey === value`).
20
+ * Does not validate filesystem or write to Supabase.
21
+ */
22
+ export function buildMediaLinkDraft(input: BuildMediaLinkDraftInput): TargetDraft<Link> {
23
+ if (!isMediaValue(input.value)) {
24
+ throw new Error(`buildMediaLinkDraft: unsupported media value: ${String(input.value)}`);
25
+ }
26
+ const name = input.name.trim();
27
+ if (name === "") {
28
+ throw new Error("buildMediaLinkDraft: name is empty");
29
+ }
30
+ const storageProvider = input.storageProvider.trim();
31
+ if (storageProvider === "") {
32
+ throw new Error("buildMediaLinkDraft: storageProvider is empty");
33
+ }
34
+ const locator = input.locator.trim();
35
+ if (locator === "") {
36
+ throw new Error("buildMediaLinkDraft: locator is empty");
37
+ }
38
+ if (!Number.isFinite(input.size) || input.size < 0) {
39
+ throw new Error("buildMediaLinkDraft: size must be a non-negative number");
40
+ }
41
+
42
+ const isList = input.value === "image-list";
43
+ const contentHash = isList ? "" : input.contentHash.trim().toLowerCase();
44
+ const mimeType = isList ? "" : input.mimeType.trim();
45
+ if (!isList && contentHash === "") {
46
+ throw new Error("buildMediaLinkDraft: contentHash is required for single-file media");
47
+ }
48
+ if (!isList && mimeType === "") {
49
+ throw new Error("buildMediaLinkDraft: mimeType is required for single-file media");
50
+ }
51
+
52
+ const original: MediaOriginal = {
53
+ storageProvider,
54
+ locator,
55
+ contentHash,
56
+ size: Math.floor(input.size),
57
+ mimeType,
58
+ };
59
+
60
+ return buildLinkTargetDraft({
61
+ name,
62
+ value: input.value,
63
+ description: input.description ?? "",
64
+ preview: "",
65
+ loaderKey: input.value,
66
+ tagList: input.tagList ?? [],
67
+ original,
68
+ });
69
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Media Link protocol — Link rows with value/loaderKey in {@link MEDIA_VALUES}.
3
+ * No separate Target category; category remains `link`.
4
+ */
5
+
6
+ /** Fixed digest algorithm for single-file media `contentHash`. */
7
+ export const MEDIA_CONTENT_HASH_ALGORITHM = "sha256" as const;
8
+
9
+ /** Hex prefix length for {@link buildMediaNameFromLocator}. */
10
+ export const MEDIA_CONTENT_HASH_PREFIX_LENGTH = 6;
11
+
12
+ export const MEDIA_VALUES = ["video", "audio", "image", "image-list"] as const;
13
+
14
+ /** Discriminator stored in both `Link.value` and `details.loaderKey`. */
15
+ export type MediaValue = (typeof MEDIA_VALUES)[number];
16
+
17
+ /**
18
+ * `Link.details.original` for media Links.
19
+ *
20
+ * - `locator` — absolute filesystem path, or (future) network URL keyed by `storageProvider`.
21
+ * - `image-list`: `locator` is an absolute directory path; only the **immediate** children
22
+ * (one level, non-recursive) are counted. `contentHash` and `mimeType` are `""`;
23
+ * `size` is the number of files in that directory (not bytes).
24
+ * - other values: `size` is byte length; `contentHash` is SHA-256 hex; `mimeType` from the file.
25
+ */
26
+ export interface MediaOriginal {
27
+ storageProvider: string;
28
+ locator: string;
29
+ /**
30
+ * SHA-256 hex of file bytes, or `""` when `value === "image-list"`.
31
+ * Algorithm is always {@link MEDIA_CONTENT_HASH_ALGORITHM}.
32
+ */
33
+ contentHash: string;
34
+ /**
35
+ * Single-file media: byte length.
36
+ * `image-list`: number of files in the directory (one level).
37
+ */
38
+ size: number;
39
+ /** e.g. `video/mp4`, or `""` when `value === "image-list"`. */
40
+ mimeType: string;
41
+ }
42
+
43
+ export function isMediaValue(value: string): value is MediaValue {
44
+ return (MEDIA_VALUES as readonly string[]).includes(value);
45
+ }
@@ -0,0 +1,64 @@
1
+ import type { MediaValue } from "./media.interface";
2
+
3
+ const VIDEO_EXT_MIME: Record<string, string> = {
4
+ mp4: "video/mp4",
5
+ m4v: "video/mp4",
6
+ mov: "video/quicktime",
7
+ mkv: "video/x-matroska",
8
+ webm: "video/webm",
9
+ avi: "video/x-msvideo",
10
+ mpeg: "video/mpeg",
11
+ mpg: "video/mpeg",
12
+ wmv: "video/x-ms-wmv",
13
+ flv: "video/x-flv",
14
+ ts: "video/mp2t",
15
+ m2ts: "video/mp2t",
16
+ };
17
+
18
+ const AUDIO_EXT_MIME: Record<string, string> = {
19
+ wav: "audio/wav",
20
+ mp3: "audio/mpeg",
21
+ m4a: "audio/mp4",
22
+ aac: "audio/aac",
23
+ flac: "audio/flac",
24
+ ogg: "audio/ogg",
25
+ oga: "audio/ogg",
26
+ opus: "audio/opus",
27
+ wma: "audio/x-ms-wma",
28
+ };
29
+
30
+ const IMAGE_EXT_MIME: Record<string, string> = {
31
+ jpg: "image/jpeg",
32
+ jpeg: "image/jpeg",
33
+ png: "image/png",
34
+ webp: "image/webp",
35
+ gif: "image/gif",
36
+ bmp: "image/bmp",
37
+ tif: "image/tiff",
38
+ tiff: "image/tiff",
39
+ };
40
+
41
+ function extensionOf(pathOrUrl: string): string {
42
+ const cleaned = pathOrUrl.split(/[?#]/)[0] ?? pathOrUrl;
43
+ const base = cleaned.replace(/\\/g, "/").split("/").pop() ?? cleaned;
44
+ const dot = base.lastIndexOf(".");
45
+ if (dot <= 0 || dot === base.length - 1) {
46
+ return "";
47
+ }
48
+ return base.slice(dot + 1).toLowerCase();
49
+ }
50
+
51
+ /** Guess MIME from path extension; `null` when unknown. */
52
+ export function guessMediaMime(locator: string, value: Exclude<MediaValue, "image-list">): string | null {
53
+ const ext = extensionOf(locator);
54
+ if (ext === "") {
55
+ return null;
56
+ }
57
+ if (value === "video") {
58
+ return VIDEO_EXT_MIME[ext] ?? null;
59
+ }
60
+ if (value === "audio") {
61
+ return AUDIO_EXT_MIME[ext] ?? null;
62
+ }
63
+ return IMAGE_EXT_MIME[ext] ?? null;
64
+ }
@@ -0,0 +1,33 @@
1
+ import { MEDIA_CONTENT_HASH_PREFIX_LENGTH } from "./media.interface";
2
+
3
+ /**
4
+ * `{basename}.{hash6}.{ext}` from an absolute (or path-like) locator and full content hash.
5
+ * Does not touch the filesystem.
6
+ */
7
+ export function buildMediaNameFromLocator(locator: string, contentHash: string): string {
8
+ const trimmedLocator = locator.trim();
9
+ if (trimmedLocator === "") {
10
+ throw new Error("buildMediaNameFromLocator: locator is empty");
11
+ }
12
+ const hash = contentHash.trim().toLowerCase();
13
+ if (hash.length < MEDIA_CONTENT_HASH_PREFIX_LENGTH) {
14
+ throw new Error(
15
+ `buildMediaNameFromLocator: contentHash must be at least ${MEDIA_CONTENT_HASH_PREFIX_LENGTH} hex chars`,
16
+ );
17
+ }
18
+ const hash6 = hash.slice(0, MEDIA_CONTENT_HASH_PREFIX_LENGTH);
19
+
20
+ const normalized = trimmedLocator.replace(/\\/g, "/");
21
+ const base = normalized.split("/").pop() ?? normalized;
22
+ if (base === "" || base === "." || base === "..") {
23
+ throw new Error(`buildMediaNameFromLocator: invalid basename from locator: ${locator}`);
24
+ }
25
+
26
+ const dot = base.lastIndexOf(".");
27
+ if (dot <= 0 || dot === base.length - 1) {
28
+ return `${base}.${hash6}`;
29
+ }
30
+ const stem = base.slice(0, dot);
31
+ const ext = base.slice(dot + 1);
32
+ return `${stem}.${hash6}.${ext}`;
33
+ }
@@ -0,0 +1,13 @@
1
+ import type { QueryFilter } from "../../core.interface";
2
+ import { CategoryLink } from "../../link/link.interface";
3
+ import type { MediaValue } from "./media.interface";
4
+
5
+ /** Dedup key: `category=link` + `value` + `name` (matches register / find). */
6
+ export function mediaLinkDedupFilters(input: { value: MediaValue; 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
+ * Node-only media 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 { CheckMediaAvailabilityInput, MediaAvailabilityResult } from "./media.access";
7
+ export { checkMediaAvailability, probeMediaOriginal } from "./media.access";
8
+ export type {
9
+ FindMediaLinkInput,
10
+ RegisterMediaLinkInput,
11
+ RegisterMediaLinkResult,
12
+ } from "./media.service";
13
+ export { findMediaLink, registerMediaLink } from "./media.service";