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.
- package/README.md +20 -0
- package/dist/browser.d.ts +6 -2
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +1 -1
- package/dist/chunks/{task-queue-DCY_0Qj0.js → task-queue-DufG7PlX.js} +295 -121
- package/dist/chunks/task-queue-DufG7PlX.js.map +1 -0
- package/dist/config/config.api.d.ts +61 -0
- package/dist/config/config.api.d.ts.map +1 -0
- package/dist/{service → config}/config.interface.d.ts +1 -3
- package/dist/config/config.interface.d.ts.map +1 -0
- package/dist/config/index.d.ts +9 -0
- package/dist/config/index.d.ts.map +1 -0
- package/dist/node/runtime-state/service-runtime-state.types.d.ts +1 -1
- package/dist/node/runtime-state/service-runtime-state.types.d.ts.map +1 -1
- package/dist/node/service-guard/registry-slot-guard-step.d.ts +1 -1
- package/dist/node/service-guard/registry-slot-guard-step.d.ts.map +1 -1
- package/dist/node/service-guard/service-guard.interface.d.ts +1 -1
- package/dist/node/service-guard/service-guard.interface.d.ts.map +1 -1
- package/dist/node/service-host/claim-registry-slot.d.ts +1 -1
- package/dist/node/service-host/claim-registry-slot.d.ts.map +1 -1
- package/dist/node/service-host/service-host.d.ts +1 -1
- package/dist/node/service-host/service-host.d.ts.map +1 -1
- package/dist/node/service-host/single-process-service.d.ts +1 -1
- package/dist/node/service-host/single-process-service.d.ts.map +1 -1
- package/dist/node.d.ts +2 -0
- package/dist/node.d.ts.map +1 -1
- package/dist/node.js +211 -6
- package/dist/node.js.map +1 -1
- package/dist/service/index.d.ts +5 -10
- package/dist/service/index.d.ts.map +1 -1
- package/dist/service/registry/index.d.ts +14 -0
- package/dist/service/registry/index.d.ts.map +1 -0
- package/dist/service/{registry-lifecycle.d.ts → registry/registry-lifecycle.d.ts} +1 -1
- package/dist/service/registry/registry-lifecycle.d.ts.map +1 -0
- package/dist/service/{config.api.d.ts → registry/registry.api.d.ts} +5 -65
- package/dist/service/registry/registry.api.d.ts.map +1 -0
- package/dist/service/registry/registry.constant.d.ts +23 -0
- package/dist/service/registry/registry.constant.d.ts.map +1 -0
- package/dist/service/registry/registry.interface.d.ts +15 -0
- package/dist/service/registry/registry.interface.d.ts.map +1 -0
- package/dist/service/{registry.service.d.ts → registry/registry.service.d.ts} +23 -15
- package/dist/service/registry/registry.service.d.ts.map +1 -0
- package/dist/service/service.interface.d.ts +0 -10
- package/dist/service/service.interface.d.ts.map +1 -1
- package/dist/shared/media/index.d.ts +14 -0
- package/dist/shared/media/index.d.ts.map +1 -0
- package/dist/shared/media/media.build.d.ts +20 -0
- package/dist/shared/media/media.build.d.ts.map +1 -0
- package/dist/shared/media/media.interface.d.ts +38 -0
- package/dist/shared/media/media.interface.d.ts.map +1 -0
- package/dist/shared/media/media.mime.d.ts +4 -0
- package/dist/shared/media/media.mime.d.ts.map +1 -0
- package/dist/shared/media/media.name.d.ts +6 -0
- package/dist/shared/media/media.name.d.ts.map +1 -0
- package/dist/shared/media/media.query.d.ts +8 -0
- package/dist/shared/media/media.query.d.ts.map +1 -0
- package/dist/shared/media/node/index.d.ts +9 -0
- package/dist/shared/media/node/index.d.ts.map +1 -0
- package/dist/shared/media/node/media.access.d.ts +40 -0
- package/dist/shared/media/node/media.access.d.ts.map +1 -0
- package/dist/shared/media/node/media.service.d.ts +30 -0
- package/dist/shared/media/node/media.service.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/browser.ts +21 -9
- package/src/config/config.api.ts +72 -0
- package/src/{service → config}/config.interface.ts +1 -4
- package/src/config/index.ts +9 -0
- package/src/node/runtime-state/create-service-runtime-state-store.ts +1 -1
- package/src/node/runtime-state/service-runtime-state.types.ts +1 -1
- package/src/node/service-guard/registry-slot-guard-step.ts +4 -1
- package/src/node/service-guard/run-service-guard-tick.ts +1 -1
- package/src/node/service-guard/service-guard.interface.ts +1 -1
- package/src/node/service-host/claim-registry-slot.ts +2 -2
- package/src/node/service-host/service-host.ts +2 -2
- package/src/node/service-host/single-process-service.ts +1 -1
- package/src/node.ts +13 -0
- package/src/service/index.ts +30 -40
- package/src/service/registry/index.ts +65 -0
- package/src/service/{registry-lifecycle.ts → registry/registry-lifecycle.ts} +3 -3
- package/src/service/{config.api.ts → registry/registry.api.ts} +6 -80
- package/src/service/registry/registry.constant.ts +17 -0
- package/src/service/registry/registry.interface.ts +16 -0
- package/src/service/{registry.service.ts → registry/registry.service.ts} +66 -65
- package/src/service/service.interface.ts +0 -12
- package/src/shared/media/README.md +109 -0
- package/src/shared/media/index.ts +19 -0
- package/src/shared/media/media.build.ts +69 -0
- package/src/shared/media/media.interface.ts +45 -0
- package/src/shared/media/media.mime.ts +64 -0
- package/src/shared/media/media.name.ts +33 -0
- package/src/shared/media/media.query.ts +13 -0
- package/src/shared/media/node/index.ts +13 -0
- package/src/shared/media/node/media.access.ts +148 -0
- package/src/shared/media/node/media.service.ts +129 -0
- package/dist/chunks/task-queue-DCY_0Qj0.js.map +0 -1
- package/dist/service/config.api.d.ts.map +0 -1
- package/dist/service/config.interface.d.ts.map +0 -1
- package/dist/service/registry-lifecycle.d.ts.map +0 -1
- 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 {
|
|
13
|
-
import type {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import { getService } from "
|
|
18
|
-
import {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
89
|
-
/** Logical service
|
|
90
|
-
|
|
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
|
|
93
|
+
export interface AppendSystemRegistryEmptySlotsOutcome {
|
|
96
94
|
config: Config;
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
622
|
-
*
|
|
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
|
|
626
|
-
input:
|
|
627
|
-
): Promise<
|
|
628
|
-
const
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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: "
|
|
643
|
+
module: "appendSystemRegistryEmptySlots",
|
|
638
644
|
traceId: input.traceId,
|
|
639
|
-
labels: {
|
|
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: {
|
|
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
|
|
654
|
-
|
|
655
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: {
|
|
682
|
+
data: { serviceValue, appended: count, capacityBefore, capacityAfter, attempt },
|
|
688
683
|
});
|
|
689
684
|
|
|
690
|
-
return {
|
|
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: {
|
|
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: {
|
|
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
|
-
"[
|
|
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";
|