target-supabase-sdk 0.2.23 → 0.2.25

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 (108) hide show
  1. package/README.md +3 -3
  2. package/dist/browser.d.ts +3 -1
  3. package/dist/browser.d.ts.map +1 -1
  4. package/dist/browser.js +3 -2
  5. package/dist/browser.js.map +1 -1
  6. package/dist/chunks/{enable-C7lFooTG.js → enable-DkiKPHDl.js} +20 -121
  7. package/dist/chunks/enable-DkiKPHDl.js.map +1 -0
  8. package/dist/chunks/{list.api-BzbTJBsM.js → list.api-B-aM4vzv.js} +46 -10
  9. package/dist/chunks/list.api-B-aM4vzv.js.map +1 -0
  10. package/dist/chunks/{task-queue-Ba8S21I6.js → task-queue-BYfO0Nzm.js} +212 -28
  11. package/dist/chunks/task-queue-BYfO0Nzm.js.map +1 -0
  12. package/dist/link/index.d.ts +2 -0
  13. package/dist/link/index.d.ts.map +1 -1
  14. package/dist/link/link.query.d.ts +9 -0
  15. package/dist/link/link.query.d.ts.map +1 -0
  16. package/dist/node.d.ts +2 -0
  17. package/dist/node.d.ts.map +1 -1
  18. package/dist/node.js +157 -27
  19. package/dist/node.js.map +1 -1
  20. package/dist/preload.js +3 -3
  21. package/dist/shared/feed/feed.query.d.ts.map +1 -1
  22. package/dist/shared/idea/idea.build.d.ts +35 -0
  23. package/dist/shared/idea/idea.build.d.ts.map +1 -0
  24. package/dist/shared/idea/idea.interface.d.ts +23 -0
  25. package/dist/shared/idea/idea.interface.d.ts.map +1 -0
  26. package/dist/shared/idea/idea.normalize.d.ts +3 -0
  27. package/dist/shared/idea/idea.normalize.d.ts.map +1 -0
  28. package/dist/shared/idea/idea.parse.d.ts +3 -0
  29. package/dist/shared/idea/idea.parse.d.ts.map +1 -0
  30. package/dist/shared/idea/idea.query.d.ts +6 -0
  31. package/dist/shared/idea/idea.query.d.ts.map +1 -0
  32. package/dist/shared/idea/idea.schema.d.ts +60 -0
  33. package/dist/shared/idea/idea.schema.d.ts.map +1 -0
  34. package/dist/shared/idea/index.d.ts +15 -0
  35. package/dist/shared/idea/index.d.ts.map +1 -0
  36. package/dist/shared/idea/node/idea.hash.d.ts +3 -0
  37. package/dist/shared/idea/node/idea.hash.d.ts.map +1 -0
  38. package/dist/shared/idea/node/idea.locator.d.ts +9 -0
  39. package/dist/shared/idea/node/idea.locator.d.ts.map +1 -0
  40. package/dist/shared/idea/node/index.d.ts +3 -0
  41. package/dist/shared/idea/node/index.d.ts.map +1 -0
  42. package/dist/shared/log/core/log-batch.d.ts +4 -4
  43. package/dist/shared/log/core/log-batch.d.ts.map +1 -1
  44. package/dist/shared/log/index.d.ts +2 -1
  45. package/dist/shared/log/index.d.ts.map +1 -1
  46. package/dist/shared/log/spool/collector.d.ts +1 -1
  47. package/dist/shared/log/spool/collector.d.ts.map +1 -1
  48. package/dist/shared/log/spool/file.d.ts +2 -8
  49. package/dist/shared/log/spool/file.d.ts.map +1 -1
  50. package/dist/shared/log/spool/writer.d.ts.map +1 -1
  51. package/dist/shared/log/upload/flush.d.ts.map +1 -1
  52. package/dist/shared/log/upload/interface.d.ts +3 -11
  53. package/dist/shared/log/upload/interface.d.ts.map +1 -1
  54. package/dist/shared/media/media.query.d.ts.map +1 -1
  55. package/dist/shared/review/review.query.d.ts.map +1 -1
  56. package/dist/shared/utils/config-path.utils.d.ts +1 -1
  57. package/dist/shared/utils/postgrest.utils.d.ts +1 -1
  58. package/dist/task/index.d.ts +1 -1
  59. package/dist/task/local-task-registry.d.ts +1 -1
  60. package/dist/trigger/trigger.api.d.ts +6 -1
  61. package/dist/trigger/trigger.api.d.ts.map +1 -1
  62. package/package.json +1 -1
  63. package/src/browser.ts +23 -2
  64. package/src/link/index.ts +2 -0
  65. package/src/link/link.query.ts +18 -0
  66. package/src/node.ts +8 -0
  67. package/src/shared/feed/README.md +1 -1
  68. package/src/shared/feed/feed.query.ts +2 -7
  69. package/src/shared/idea/README.md +82 -0
  70. package/src/shared/idea/VALIDATION-MATRIX.md +69 -0
  71. package/src/shared/idea/idea.build.ts +160 -0
  72. package/src/shared/idea/idea.interface.ts +34 -0
  73. package/src/shared/idea/idea.normalize.ts +17 -0
  74. package/src/shared/idea/idea.parse.ts +16 -0
  75. package/src/shared/idea/idea.query.ts +16 -0
  76. package/src/shared/idea/idea.schema.ts +32 -0
  77. package/src/shared/idea/index.ts +21 -0
  78. package/src/shared/idea/node/idea.hash.ts +6 -0
  79. package/src/shared/idea/node/idea.locator.ts +61 -0
  80. package/src/shared/idea/node/index.ts +7 -0
  81. package/src/shared/log/README.md +2 -3
  82. package/src/shared/log/core/log-batch.ts +12 -10
  83. package/src/shared/log/index.ts +2 -1
  84. package/src/shared/log/spool/collector.ts +46 -20
  85. package/src/shared/log/spool/file.ts +12 -25
  86. package/src/shared/log/spool/writer.ts +2 -7
  87. package/src/shared/log/upload/flush.ts +23 -38
  88. package/src/shared/log/upload/interface.ts +3 -12
  89. package/src/shared/media/README.md +1 -1
  90. package/src/shared/media/media.query.ts +2 -7
  91. package/src/shared/review/review.query.ts +2 -7
  92. package/src/shared/utils/config-path.utils.ts +1 -1
  93. package/src/shared/utils/postgrest.utils.ts +1 -1
  94. package/src/task/index.ts +1 -1
  95. package/src/task/local-task-registry.ts +1 -1
  96. package/src/trigger/trigger.api.ts +6 -1
  97. package/dist/chunks/enable-C7lFooTG.js.map +0 -1
  98. package/dist/chunks/list.api-BzbTJBsM.js.map +0 -1
  99. package/dist/chunks/task-queue-Ba8S21I6.js.map +0 -1
  100. package/dist/resource/resource.interface.d.ts +0 -20
  101. package/dist/resource/resource.interface.d.ts.map +0 -1
  102. package/dist/shared/log/upload/batch-id.d.ts +0 -13
  103. package/dist/shared/log/upload/batch-id.d.ts.map +0 -1
  104. package/dist/task/task.utils.d.ts +0 -1
  105. package/dist/task/task.utils.d.ts.map +0 -1
  106. package/src/resource/resource.interface.ts +0 -37
  107. package/src/shared/log/upload/batch-id.ts +0 -43
  108. package/src/task/task.utils.ts +0 -0
@@ -0,0 +1,82 @@
1
+ # Idea Links (`shared/idea`)
2
+
3
+ Ideas are a **convention on `Link`**, not a new Target category (`category` stays `link`).
4
+
5
+ ```text
6
+ src/shared/idea/
7
+ index.ts ← browser-safe barrel ONLY (never export ./node)
8
+ idea.interface.ts
9
+ idea.schema.ts
10
+ idea.build.ts
11
+ idea.normalize.ts
12
+ idea.parse.ts
13
+ idea.query.ts
14
+ README.md
15
+ VALIDATION-MATRIX.md
16
+ node/ ← Node-only (hash + temp download URL helpers)
17
+ index.ts
18
+ idea.hash.ts
19
+ idea.locator.ts
20
+ ```
21
+
22
+ ## Variants
23
+
24
+ | Kind | `value` | `name` | `original.content` | `original.url` |
25
+ |------|---------|--------|--------------------|----------------|
26
+ | Text note | `idea` | Title (caller) | Body text | Source page URL, etc. |
27
+ | Clipboard image | `idea` | **`idea.image`** (fixed) | SHA-256 hex of bytes | Temp download URL |
28
+
29
+ Filter rows with **`value=idea`** only — do not query `details.loaderKey`. When assembling drafts, set **`loaderKey === value === "idea"`**.
30
+
31
+ There is **no content-hash dedup** for image ideas.
32
+
33
+ ## Package entry split
34
+
35
+ | Consumer import | What you get | Source |
36
+ |-----------------|--------------|--------|
37
+ | `from "target-supabase-sdk"` | Draft builders, schemas, parse, list filters | `shared/idea/index.ts` → `browser.ts` |
38
+ | `from "target-supabase-sdk/node"` | + `hashIdeaPasteImage`, temp download locators | `shared/idea/node/` → `node.ts` |
39
+
40
+ **Do not** re-export `./node` from `index.ts` / `browser.ts`.
41
+
42
+ ```typescript
43
+ import {
44
+ buildIdeaLinkDraft,
45
+ buildIdeaImageLinkDraft,
46
+ ideaLinkValueFilters,
47
+ IDEA_LINK_VALUE,
48
+ IDEA_IMAGE_LINK_NAME,
49
+ ideaCreateSchema,
50
+ parseIdeaOriginal,
51
+ } from "target-supabase-sdk";
52
+
53
+ import {
54
+ hashIdeaPasteImage,
55
+ buildIdeaTempDownloadLocator,
56
+ buildIdeaPasteRelativePath,
57
+ } from "target-supabase-sdk/node";
58
+ ```
59
+
60
+ Quick-note (chrome extension) should wrap `buildIdeaLinkDraft` **locally** with extension tags — not via a dedicated SDK export.
61
+
62
+ ---
63
+
64
+ ## Link field rules
65
+
66
+ | Field | Rule |
67
+ |-------|------|
68
+ | `value` | Always `idea` |
69
+ | `details.loaderKey` | Always `idea` |
70
+ | Text `name` | Title; max 500; must not be `idea.image` |
71
+ | Image `name` | Fixed `idea.image` via `buildIdeaImageLinkDraft` |
72
+ | `description` / `preview` | Default `""` |
73
+ | `tagList` | User/system tags; image default `[ ".raw" ]` |
74
+
75
+ See [VALIDATION-MATRIX.md](./VALIDATION-MATRIX.md) for limits across Zod, builders, and HTTP.
76
+
77
+ ---
78
+
79
+ ## Related
80
+
81
+ - upload-service: `.cursor/skills/idea-link/SKILL.md`
82
+ - `.cursor/skills/link-conventions/SKILL.md`
@@ -0,0 +1,69 @@
1
+ # Idea Link validation matrix
2
+
3
+ Single reference for **where** each constraint is enforced. Limits match upload-service `POST/PATCH /v0/upload/idea` and paste-image behavior.
4
+
5
+ Legend: **Z** = `ideaOriginalSchema` / `ideaCreateSchema` / `ideaUpdateSchema` (Zod); **B** = `buildIdeaLinkDraft` / `buildIdeaImageLinkDraft`; **H** = upload-service HTTP handler (Zod on request body); **P** = paste-image route (MIME, size, empty buffer).
6
+
7
+ ## Row identity
8
+
9
+ | Rule | Z | B | H | Notes |
10
+ |------|---|---|---|-------|
11
+ | `category=link` | — | — | — | Set by `createTarget` / SDK category, not in draft builder |
12
+ | `value=idea` | — | ✓ | — | Fixed in builders |
13
+ | `loaderKey=idea` | — | ✓ | — | Same as `value` |
14
+ | Text `name` non-empty | ✓ create | ✓ | ✓ | `ideaCreateSchema` / builder |
15
+ | Text `name` max 500 | ✓ create/update | ✓ | ✓ | |
16
+ | Text `name` ≠ `idea.image` | — | ✓ | — | Use `buildIdeaImageLinkDraft` for images |
17
+ | Image `name=idea.image` | — | ✓ | — | Fixed in `buildIdeaImageLinkDraft` |
18
+ | No title uniqueness / dedup | — | — | — | By design |
19
+
20
+ ## `details.original`
21
+
22
+ | Field | Z | B | H | Limit / rule |
23
+ |-------|---|---|---|----------------|
24
+ | `content` | ✓ min 1, max 100_000 | ✓ same | ✓ create/update | Text body or image SHA-256 hex |
25
+ | `reviewList` | ✓ max 32 items, each 1–8000 | ✓ | ✓ | Normalized (trim, dedupe) |
26
+ | `url` | ✓ max 2000, default `""` | ✓ | ✓ | Image: temp download URL required in builder |
27
+ | `heat` | ✓ int ≥ 0, default 0 | ✓ floor 0 | — | PATCH +1 is upload-service only |
28
+ | Image `reviewList` | — | ✓ `[]` | — | Builder fixed |
29
+ | Image `heat` | — | ✓ `0` | — | Builder fixed |
30
+
31
+ ## `tagList`
32
+
33
+ | Rule | Z create | B text | B image | Notes |
34
+ |------|----------|--------|---------|-------|
35
+ | Max 32 tags | ✓ | ✓ | ✓ | |
36
+ | Each tag 1–128 chars | ✓ | ✓ | — | Builder checks length on text tags |
37
+ | Default image tags | — | — | ✓ `[ ".raw" ]` if omitted | |
38
+ | Quick-note `.raw` | — | — | — | Extension local wrapper only |
39
+
40
+ ## Paste-image (bytes → Link)
41
+
42
+ | Rule | P | B image | Node hash/locator |
43
+ |------|---|---------|-------------------|
44
+ | MIME whitelist | ✓ | — | — |
45
+ | Non-empty buffer | ✓ | — | — |
46
+ | Max file size | ✓ `TEMP_FS_MAX_FILE_SIZE` | — | — |
47
+ | Optional paste `content` field | — | — | upload-service: caption → `original.content`; empty → hash |
48
+ | Content hash (file path) | — | — | `hashIdeaPasteImage` + `buildIdeaPasteRelativePath` |
49
+ | Stored `original.content` | ✓ min 1 | ✓ | user text or hash fallback |
50
+ | Temp path `idea/paste/{hash}.{ext}` | — | — | `buildIdeaPasteRelativePath` |
51
+ | `original.url` temp download | — | ✓ non-empty URL | `buildIdeaTempDownloadLocator` |
52
+ | Content-hash dedup before create | — | — | **Not implemented** |
53
+
54
+ ## Query / read
55
+
56
+ | Rule | SDK | upload-service |
57
+ |------|-----|----------------|
58
+ | Filter by `value=idea` | `ideaLinkValueFilters` | list/get/delete |
59
+ | Image-only list | `ideaImageLinkFilters` | `linkName=idea.image` |
60
+ | Parse row `original` | `parseIdeaOriginal` | `toIdeaView` throws if invalid |
61
+ | Search `name` / `content` ilike | — | list API only |
62
+
63
+ ## Error style
64
+
65
+ | Layer | Behavior |
66
+ |-------|----------|
67
+ | Zod (H / optional client) | Field errors in HTTP 400 |
68
+ | `buildIdea*Draft` | `throw new Error("buildIdeaLinkDraft: …")` |
69
+ | `parseIdeaOriginal` | Returns `null` (no throw) |
@@ -0,0 +1,160 @@
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
+ IDEA_IMAGE_LINK_NAME,
6
+ IDEA_IMAGE_TAG_RAW,
7
+ IDEA_LINK_VALUE,
8
+ type IdeaOriginal,
9
+ isIdeaImageLinkName,
10
+ } from "./idea.interface";
11
+ import { normalizeStringList } from "./idea.normalize";
12
+
13
+ export interface BuildIdeaLinkDraftInput {
14
+ /** Article title — Link.name (not used for image ideas). */
15
+ name: string;
16
+ /** Body or image content hash — `details.original.content`. */
17
+ content: string;
18
+ reviewList?: readonly string[];
19
+ url?: string;
20
+ heat?: number;
21
+ tagList?: readonly string[];
22
+ description?: string;
23
+ preview?: string;
24
+ }
25
+
26
+ export interface BuildIdeaImageLinkDraftInput {
27
+ /** SHA-256 hex of image bytes — stored in `details.original.content`. */
28
+ contentHash: string;
29
+ /** Temp download or other absolute URL — `details.original.url`. */
30
+ url: string;
31
+ heat?: number;
32
+ tagList?: readonly string[];
33
+ description?: string;
34
+ preview?: string;
35
+ }
36
+
37
+ function resolveIdeaOriginal(input: {
38
+ content: string;
39
+ reviewList?: readonly string[];
40
+ url?: string;
41
+ heat?: number;
42
+ }): IdeaOriginal {
43
+ const content = input.content.trim();
44
+ if (content === "") {
45
+ throw new Error("buildIdeaLinkDraft: content is empty");
46
+ }
47
+ if (content.length > 100_000) {
48
+ throw new Error("buildIdeaLinkDraft: content exceeds 100000 characters");
49
+ }
50
+
51
+ const url = input.url?.trim() ?? "";
52
+ if (url.length > 2000) {
53
+ throw new Error("buildIdeaLinkDraft: url exceeds 2000 characters");
54
+ }
55
+
56
+ const reviewList = normalizeStringList(input.reviewList);
57
+ if (reviewList.length > 32) {
58
+ throw new Error("buildIdeaLinkDraft: reviewList exceeds 32 entries");
59
+ }
60
+ for (const entry of reviewList) {
61
+ if (entry.length > 8000) {
62
+ throw new Error("buildIdeaLinkDraft: reviewList entry exceeds 8000 characters");
63
+ }
64
+ }
65
+
66
+ return {
67
+ content,
68
+ reviewList,
69
+ url,
70
+ heat: Math.max(0, Math.floor(input.heat ?? 0)),
71
+ };
72
+ }
73
+
74
+ /**
75
+ * Assemble a text idea {@link Link} draft (`category=link`, `loaderKey === value === "idea"`).
76
+ * Does not write to Supabase or enforce title uniqueness.
77
+ */
78
+ export function buildIdeaLinkDraft(input: BuildIdeaLinkDraftInput): TargetDraft<Link> {
79
+ const name = input.name.trim();
80
+ if (name === "") {
81
+ throw new Error("buildIdeaLinkDraft: name is empty");
82
+ }
83
+ if (name.length > 500) {
84
+ throw new Error("buildIdeaLinkDraft: name exceeds 500 characters");
85
+ }
86
+ if (isIdeaImageLinkName(name)) {
87
+ throw new Error("buildIdeaLinkDraft: name is reserved for image ideas; use buildIdeaImageLinkDraft");
88
+ }
89
+
90
+ const tagList = normalizeStringList(input.tagList);
91
+ if (tagList.length > 32) {
92
+ throw new Error("buildIdeaLinkDraft: tagList exceeds 32 entries");
93
+ }
94
+ for (const tag of tagList) {
95
+ if (tag.length > 128) {
96
+ throw new Error("buildIdeaLinkDraft: tag exceeds 128 characters");
97
+ }
98
+ }
99
+
100
+ const original = resolveIdeaOriginal({
101
+ content: input.content,
102
+ reviewList: input.reviewList,
103
+ url: input.url,
104
+ heat: input.heat,
105
+ });
106
+
107
+ return buildLinkTargetDraft({
108
+ name,
109
+ value: IDEA_LINK_VALUE,
110
+ description: input.description ?? "",
111
+ preview: input.preview ?? "",
112
+ loaderKey: IDEA_LINK_VALUE,
113
+ tagList,
114
+ original,
115
+ });
116
+ }
117
+
118
+ /**
119
+ * Assemble a clipboard-image idea Link (`name=idea.image`, `value=idea`).
120
+ * Caller stores bytes and supplies hash + temp URL; no dedup by content hash.
121
+ */
122
+ export function buildIdeaImageLinkDraft(input: BuildIdeaImageLinkDraftInput): TargetDraft<Link> {
123
+ const contentHash = input.contentHash.trim();
124
+ if (contentHash === "") {
125
+ throw new Error("buildIdeaImageLinkDraft: contentHash is empty");
126
+ }
127
+ if (contentHash.length > 100_000) {
128
+ throw new Error("buildIdeaImageLinkDraft: contentHash exceeds 100000 characters");
129
+ }
130
+
131
+ const url = input.url.trim();
132
+ if (url === "") {
133
+ throw new Error("buildIdeaImageLinkDraft: url is empty");
134
+ }
135
+ if (url.length > 2000) {
136
+ throw new Error("buildIdeaImageLinkDraft: url exceeds 2000 characters");
137
+ }
138
+
139
+ const tagList = normalizeStringList(input.tagList ?? [IDEA_IMAGE_TAG_RAW]);
140
+ if (tagList.length === 0) {
141
+ throw new Error("buildIdeaImageLinkDraft: tagList is empty");
142
+ }
143
+
144
+ const original: IdeaOriginal = {
145
+ content: contentHash,
146
+ reviewList: [],
147
+ url,
148
+ heat: Math.max(0, Math.floor(input.heat ?? 0)),
149
+ };
150
+
151
+ return buildLinkTargetDraft({
152
+ name: IDEA_IMAGE_LINK_NAME,
153
+ value: IDEA_LINK_VALUE,
154
+ description: input.description ?? "",
155
+ preview: input.preview ?? "",
156
+ loaderKey: IDEA_LINK_VALUE,
157
+ tagList,
158
+ original,
159
+ });
160
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Idea Link protocol — Link rows with `value` / `loaderKey` === {@link IDEA_LINK_VALUE}.
3
+ * Clipboard images use fixed `name` {@link IDEA_IMAGE_LINK_NAME} (not a separate value).
4
+ */
5
+
6
+ export const IDEA_LINK_VALUE = "idea" as const;
7
+
8
+ /** Discriminator stored in both `Link.value` and `details.loaderKey`. */
9
+ export type IdeaLinkValue = typeof IDEA_LINK_VALUE;
10
+
11
+ /** Link.name for clipboard-image ideas (distinct from title-based text ideas). */
12
+ export const IDEA_IMAGE_LINK_NAME = "idea.image" as const;
13
+
14
+ /** Default tag for raw captures (quick-note, paste-image). Leading `.` = system tag. */
15
+ export const IDEA_IMAGE_TAG_RAW = ".raw" as const;
16
+
17
+ /** Temp sandbox prefix for pasted images (`TEMP_FS_ROOT` on upload-service). */
18
+ export const IDEA_IMAGE_TEMP_DIR = "idea/paste" as const;
19
+
20
+ /** `Link.details.original` for idea Links (text and image). */
21
+ export interface IdeaOriginal {
22
+ content: string;
23
+ reviewList: string[];
24
+ url: string;
25
+ heat: number;
26
+ }
27
+
28
+ export function isIdeaLinkValue(value: string): value is IdeaLinkValue {
29
+ return value === IDEA_LINK_VALUE;
30
+ }
31
+
32
+ export function isIdeaImageLinkName(name: string): boolean {
33
+ return name.trim() === IDEA_IMAGE_LINK_NAME;
34
+ }
@@ -0,0 +1,17 @@
1
+ /** Trim, dedupe, and drop empty strings from tag/review lists. */
2
+ export function normalizeStringList(raw: readonly string[] | undefined | null): string[] {
3
+ if (raw == null || raw.length === 0) {
4
+ return [];
5
+ }
6
+ const seen = new Set<string>();
7
+ const out: string[] = [];
8
+ for (const item of raw) {
9
+ const text = item.trim();
10
+ if (text === "" || seen.has(text)) {
11
+ continue;
12
+ }
13
+ seen.add(text);
14
+ out.push(text);
15
+ }
16
+ return out;
17
+ }
@@ -0,0 +1,16 @@
1
+ import type { IdeaOriginal } from "./idea.interface";
2
+ import { normalizeStringList } from "./idea.normalize";
3
+ import { ideaOriginalSchema } from "./idea.schema";
4
+
5
+ export function parseIdeaOriginal(original: unknown): IdeaOriginal | null {
6
+ const parsed = ideaOriginalSchema.safeParse(original);
7
+ if (!parsed.success) {
8
+ return null;
9
+ }
10
+ return {
11
+ content: parsed.data.content,
12
+ reviewList: normalizeStringList(parsed.data.reviewList),
13
+ url: parsed.data.url,
14
+ heat: parsed.data.heat,
15
+ };
16
+ }
@@ -0,0 +1,16 @@
1
+ import type { QueryFilter } from "../../core.interface";
2
+ import { CategoryLink } from "../../link/link.interface";
3
+ import { IDEA_IMAGE_LINK_NAME, IDEA_LINK_VALUE } from "./idea.interface";
4
+
5
+ /** List/get filters: `category=link` and `value=idea` (do not filter on `details.loaderKey`). */
6
+ export function ideaLinkValueFilters(): QueryFilter[] {
7
+ return [
8
+ { field: "category", operator: "eq", value: CategoryLink.LINK },
9
+ { field: "value", operator: "eq", value: IDEA_LINK_VALUE },
10
+ ];
11
+ }
12
+
13
+ /** Clipboard-image ideas only (`name=idea.image`). */
14
+ export function ideaImageLinkFilters(): QueryFilter[] {
15
+ return [...ideaLinkValueFilters(), { field: "name", operator: "eq", value: IDEA_IMAGE_LINK_NAME }];
16
+ }
@@ -0,0 +1,32 @@
1
+ import { z } from "zod";
2
+
3
+ /** Runtime shape of `Link.details.original` for value=idea. */
4
+ export const ideaOriginalSchema = z.object({
5
+ content: z.string().trim().min(1).max(100_000),
6
+ reviewList: z.array(z.string().trim().min(1).max(8000)).max(32).default([]),
7
+ url: z.string().trim().max(2000).default(""),
8
+ heat: z.number().int().min(0).default(0),
9
+ });
10
+
11
+ const ideaTagSchema = z.string().trim().min(1).max(128);
12
+
13
+ /** HTTP / API create body limits (upload-service `POST /v0/upload/idea`). */
14
+ export const ideaCreateSchema = z.object({
15
+ name: z.string().trim().min(1, "name is required").max(500),
16
+ content: z.string().trim().min(1, "content is required").max(100_000),
17
+ reviewList: z.array(z.string().trim().min(1).max(8000)).max(32).default([]),
18
+ url: z.string().trim().max(2000).default(""),
19
+ tagList: z.array(ideaTagSchema).max(32).default([]),
20
+ });
21
+
22
+ export type IdeaCreateSchemaInput = z.infer<typeof ideaCreateSchema>;
23
+
24
+ export const ideaUpdateSchema = z.object({
25
+ name: z.string().trim().min(1).max(500).optional(),
26
+ content: z.string().trim().min(1).max(100_000).optional(),
27
+ reviewList: z.array(z.string().trim().min(1).max(8000)).max(32).optional(),
28
+ url: z.string().trim().max(2000).optional(),
29
+ tagList: z.array(ideaTagSchema).max(32).optional(),
30
+ });
31
+
32
+ export type IdeaUpdateSchemaInput = z.infer<typeof ideaUpdateSchema>;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Idea Link — browser-safe public barrel only.
3
+ *
4
+ * Node-only code lives in `./node/` and is exported from `src/node.ts`.
5
+ */
6
+
7
+ export type { BuildIdeaImageLinkDraftInput, BuildIdeaLinkDraftInput } from "./idea.build";
8
+ export { buildIdeaImageLinkDraft, buildIdeaLinkDraft } from "./idea.build";
9
+ export type { IdeaOriginal } from "./idea.interface";
10
+ export {
11
+ IDEA_IMAGE_LINK_NAME,
12
+ IDEA_IMAGE_TAG_RAW,
13
+ IDEA_IMAGE_TEMP_DIR,
14
+ IDEA_LINK_VALUE,
15
+ isIdeaImageLinkName,
16
+ } from "./idea.interface";
17
+ export { normalizeStringList } from "./idea.normalize";
18
+ export { parseIdeaOriginal } from "./idea.parse";
19
+ export { ideaLinkValueFilters } from "./idea.query";
20
+ export type { IdeaCreateSchemaInput, IdeaUpdateSchemaInput } from "./idea.schema";
21
+ export { ideaCreateSchema, ideaUpdateSchema } from "./idea.schema";
@@ -0,0 +1,6 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ /** SHA-256 hex of image bytes (filename + `details.original.content` for paste-image ideas). */
4
+ export function hashIdeaPasteImage(buffer: Buffer): string {
5
+ return createHash("sha256").update(buffer).digest("hex");
6
+ }
@@ -0,0 +1,61 @@
1
+ import { IDEA_IMAGE_TEMP_DIR } from "../idea.interface";
2
+
3
+ /** Path suffix on upload-service public URL for temp file download. */
4
+ export const IDEA_TEMP_DOWNLOAD_PATH_SUFFIX = "/v0/upload/temp/download";
5
+
6
+ /** Flat path under temp sandbox: `idea/paste/{sha256}.{ext}`. */
7
+ export function buildIdeaPasteRelativePath(contentHash: string, ext: string): string {
8
+ const hash = contentHash.trim();
9
+ const extension = ext.trim().replace(/^\./, "");
10
+ if (hash === "" || extension === "") {
11
+ throw new Error("buildIdeaPasteRelativePath: contentHash and ext are required");
12
+ }
13
+ return `${IDEA_IMAGE_TEMP_DIR}/${hash}.${extension}`;
14
+ }
15
+
16
+ function normalizeRelativeSandboxPath(path: string): string | null {
17
+ const normalized = path.replace(/\\/g, "/").trim();
18
+ if (normalized === "" || normalized.startsWith("/") || normalized.includes("..")) {
19
+ return null;
20
+ }
21
+ return normalized;
22
+ }
23
+
24
+ /** Build absolute locator stored in `details.original.url`. */
25
+ export function buildIdeaTempDownloadLocator(relativePath: string, publicBaseUrl: string): string {
26
+ const sanitized = normalizeRelativeSandboxPath(relativePath);
27
+ if (sanitized == null) {
28
+ throw new Error("buildIdeaTempDownloadLocator: invalid relativePath");
29
+ }
30
+ const base = publicBaseUrl.trim().replace(/\/+$/, "");
31
+ const url = new URL(`${base}${IDEA_TEMP_DOWNLOAD_PATH_SUFFIX}`);
32
+ url.searchParams.set("path", sanitized);
33
+ return url.href;
34
+ }
35
+
36
+ /** Parse temp download URL → sandbox-relative path, or null when not a temp download URL. */
37
+ export function parseIdeaTempDownloadLocator(locator: string): string | null {
38
+ const trimmed = locator.trim();
39
+ if (trimmed === "") {
40
+ return null;
41
+ }
42
+
43
+ let parsed: URL;
44
+ try {
45
+ parsed = new URL(trimmed, "http://localhost");
46
+ } catch {
47
+ return null;
48
+ }
49
+
50
+ const pathname = parsed.pathname.replace(/\\/g, "/");
51
+ if (!pathname.endsWith(IDEA_TEMP_DOWNLOAD_PATH_SUFFIX)) {
52
+ return null;
53
+ }
54
+
55
+ const pathParam = parsed.searchParams.get("path");
56
+ if (pathParam == null || pathParam.trim() === "") {
57
+ return null;
58
+ }
59
+
60
+ return normalizeRelativeSandboxPath(pathParam.trim());
61
+ }
@@ -0,0 +1,7 @@
1
+ export { hashIdeaPasteImage } from "./idea.hash";
2
+ export {
3
+ buildIdeaPasteRelativePath,
4
+ buildIdeaTempDownloadLocator,
5
+ IDEA_TEMP_DOWNLOAD_PATH_SUFFIX,
6
+ parseIdeaTempDownloadLocator,
7
+ } from "./idea.locator";
@@ -18,8 +18,7 @@ src/shared/log/
18
18
 
19
19
  upload/ # shared Supabase upload pipeline (Node)
20
20
  env.ts # LOG_PERSIST_ENABLED, SERVICE, PROCESS, runtime dir
21
- interface.ts # LogBatch meta, loader keys, lane type
22
- batch-id.ts # computeLogBatchIdempotencyKey
21
+ interface.ts # LogBatch meta, LOG_BATCH_LIST_VALUE, lane type
23
22
  flush.ts # buildLogListDraft, postLogBatch
24
23
  hook.ts # LogManager → spool writer offer bridge
25
24
  logger.ts # internal pipeline loggers (no feedback loop)
@@ -58,7 +57,7 @@ App code
58
57
  → {spoolRoot}/{serviceId}/{role}/*.tmp
59
58
 
60
59
  Guard collect-log runner (1 min tick)
61
- → read *.tmp → postLogBatch → rename *.json
60
+ → read *.tmp → postLogBatch (List value=log-batch, name=service) → rename *.json / .synced marker
62
61
  → GC old tmp/json (once per day; 7 d retention)
63
62
 
64
63
  log-service (downstream)
@@ -5,8 +5,8 @@
5
5
  */
6
6
 
7
7
  import type { List } from "../../../list/list.interface";
8
- import type { LogEntry } from "../core/log-manager";
9
- import { LOG_PERSIST_LOADER_KEY, type LogBatchMeta, type LogPersistLane } from "../upload/interface";
8
+ import { LOG_BATCH_LIST_VALUE, type LogBatchMeta, type LogPersistLane } from "../upload/interface";
9
+ import type { LogEntry } from "./log-manager";
10
10
 
11
11
  const LANES: ReadonlySet<string> = new Set<LogPersistLane>(["fast", "medium", "slow"]);
12
12
 
@@ -26,8 +26,8 @@ export interface FlatLogEntry extends LogEntry {
26
26
  process: string;
27
27
  lane: LogPersistLane;
28
28
  pid: number;
29
- /** Idempotency key of the source batch. */
30
- batchId: string;
29
+ /** Source List row id. */
30
+ listId: string;
31
31
  }
32
32
 
33
33
  function asRecord(value: unknown): Record<string, unknown> | null {
@@ -61,8 +61,7 @@ export function parseLogBatchMeta(rawMeta: unknown): LogBatchMeta | null {
61
61
  const service = asString(source.service);
62
62
  const processName = asString(source.process);
63
63
  const laneRaw = asString(source.lane);
64
- const idempotencyKey = asString(source.idempotencyKey);
65
- if (service == null || processName == null || laneRaw == null || !LANES.has(laneRaw) || idempotencyKey == null) {
64
+ if (service == null || processName == null || laneRaw == null || !LANES.has(laneRaw)) {
66
65
  return null;
67
66
  }
68
67
 
@@ -75,7 +74,6 @@ export function parseLogBatchMeta(rawMeta: unknown): LogBatchMeta | null {
75
74
  from: asFiniteNumber(source.from),
76
75
  to: asFiniteNumber(source.to),
77
76
  flushedAt: asFiniteNumber(source.flushedAt) ?? 0,
78
- idempotencyKey,
79
77
  };
80
78
  }
81
79
 
@@ -93,9 +91,9 @@ export function isLogEntry(value: unknown): value is LogEntry {
93
91
  );
94
92
  }
95
93
 
96
- /** Is this List a persisted log batch (by loaderKey)? */
94
+ /** Is this List a persisted log batch (by `value`)? */
97
95
  export function isLogBatchList(list: List): boolean {
98
- return list.details?.loaderKey === LOG_PERSIST_LOADER_KEY;
96
+ return list.value === LOG_BATCH_LIST_VALUE;
99
97
  }
100
98
 
101
99
  /**
@@ -106,6 +104,10 @@ export function decodeLogBatch(list: List): DecodedLogBatch | null {
106
104
  if (!isLogBatchList(list)) {
107
105
  return null;
108
106
  }
107
+ const loaderKey = list.details?.loaderKey;
108
+ if (typeof loaderKey === "string" && loaderKey.endsWith(".bad")) {
109
+ return null;
110
+ }
109
111
  const meta = parseLogBatchMeta(list.details?.meta);
110
112
  if (meta == null) {
111
113
  return null;
@@ -142,7 +144,7 @@ export function flattenLogBatch(batch: DecodedLogBatch): FlatLogEntry[] {
142
144
  process: batch.meta.process,
143
145
  lane: batch.meta.lane,
144
146
  pid: batch.meta.pid,
145
- batchId: batch.meta.idempotencyKey,
147
+ listId: batch.listId,
146
148
  }));
147
149
  }
148
150
 
@@ -42,5 +42,6 @@ export {
42
42
  resolveLogData,
43
43
  withModule,
44
44
  } from "./core/log-scope";
45
+ export { formatLogSpoolBatchTimestamp } from "./spool/file";
45
46
  export type { LogBatchMeta, LogPersistLane } from "./upload/interface";
46
- export { LOG_BATCH_LOADER_KEY, LOG_PERSIST_LOADER_KEY } from "./upload/interface";
47
+ export { LOG_BATCH_LIST_VALUE } from "./upload/interface";