@arcote.tech/arc-files 0.7.13

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/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@arcote.tech/arc-files",
3
+ "type": "module",
4
+ "version": "0.7.13",
5
+ "private": false,
6
+ "description": "Generic file upload fragment for Arc framework — S3 presigned upload + provider file_id binding",
7
+ "main": "./src/index.ts",
8
+ "types": "./src/index.ts",
9
+ "scripts": {
10
+ "type-check": "tsc --noEmit"
11
+ },
12
+ "peerDependencies": {
13
+ "@arcote.tech/arc": "^0.7.13",
14
+ "@arcote.tech/arc-ds": "^0.7.13",
15
+ "@arcote.tech/platform": "^0.7.13",
16
+ "@aws-sdk/client-s3": "^3.658.0",
17
+ "@aws-sdk/s3-request-presigner": "^3.658.0",
18
+ "lucide-react": ">=0.400.0",
19
+ "react": "^18.0.0 || ^19.0.0",
20
+ "typescript": "^5.0.0"
21
+ },
22
+ "devDependencies": {
23
+ "@types/bun": "latest",
24
+ "@types/react": "^19.0.0"
25
+ }
26
+ }
@@ -0,0 +1,289 @@
1
+ /// <reference path="../arc.d.ts" />
2
+ import {
3
+ aggregate,
4
+ date,
5
+ mergeUnsafe,
6
+ number,
7
+ object,
8
+ string,
9
+ type ArcAggregateElement,
10
+ type ArcId,
11
+ } from "@arcote.tech/arc";
12
+ import type { S3Bridge } from "../s3/s3-client";
13
+ import type { FileProviderName } from "../types";
14
+ import type { FileTypeData } from "../file-type-builder";
15
+
16
+ /**
17
+ * Built-in fields — wspólne dla wszystkich file types. Zero permission
18
+ * assumptions; identyfikatory scope (accountId, workspaceId itp.) idą do
19
+ * `.withMetadata()` per fileType.
20
+ */
21
+ const builtInFields = {
22
+ name: string(),
23
+ mime: string(),
24
+ size: number(),
25
+ s3Bucket: string(),
26
+ s3Key: string(),
27
+ status: string(), // "pending" | "uploaded" | "failed"
28
+ providerFileIdsJson: string().optional(),
29
+ createdAt: date(),
30
+ uploadedAt: date().optional(),
31
+ };
32
+
33
+ /**
34
+ * Fabryka aggregate'u dla pojedynczego file type. Builder
35
+ * (`ArcFileTypeBuilder.build()`) zbiera state i woła tę funkcję.
36
+ *
37
+ * - fields = `mergeUnsafe(builtIn, metadata)` — custom fields są w schema,
38
+ * indexowalne i filtrowalne na poziomie dataStore (`where: {workspaceId}`).
39
+ * - protectBy chain'owane per `data.protections` — arc-core OR-dispatch
40
+ * po `token.name`.
41
+ * - Built-in mutations: requestUpload, confirmUpload, markFailed,
42
+ * bindProviderFileId, remove.
43
+ * - Custom mutations z `data.mutations` — generuje `<name>Performed` event
44
+ * + `<name>` mutation method.
45
+ */
46
+ export function createFileTypeAggregate<const Data extends FileTypeData>(
47
+ data: Data,
48
+ s3: S3Bridge,
49
+ fileId: ArcId<any>,
50
+ ): ArcAggregateElement<Data["name"], ArcId<any>, any, any, any, any> {
51
+ const metadataShape = data.metadata;
52
+ const fullShape = mergeUnsafe(builtInFields, metadataShape);
53
+
54
+ // `requestUpload` params zawiera built-in (name, mime, size) + WSZYSTKIE
55
+ // custom metadata fields — consumer dostarcza je przy uploadzie (np.
56
+ // accountId, opcjonalnie workspaceId).
57
+ const requestUploadParams = mergeUnsafe(
58
+ {
59
+ _id: fileId,
60
+ name: string(),
61
+ mime: string(),
62
+ size: number(),
63
+ },
64
+ metadataShape,
65
+ );
66
+
67
+ // `fileRequested` event payload = `requestUploadParams` + s3 fields
68
+ // dodane przez handler.
69
+ const fileRequestedPayload = mergeUnsafe(
70
+ {
71
+ _id: fileId,
72
+ name: string(),
73
+ mime: string(),
74
+ size: number(),
75
+ s3Bucket: string(),
76
+ s3Key: string(),
77
+ },
78
+ metadataShape,
79
+ );
80
+
81
+ let agg = aggregate(data.name, fileId, fullShape)
82
+ .publicEvent(
83
+ "fileRequested",
84
+ fileRequestedPayload,
85
+ async (ctx, event) => {
86
+ const p = event.payload as any;
87
+ // Build row object: built-in fields + wszystkie custom metadata
88
+ const row: Record<string, any> = {
89
+ name: p.name,
90
+ mime: p.mime,
91
+ size: p.size,
92
+ s3Bucket: p.s3Bucket,
93
+ s3Key: p.s3Key,
94
+ status: "pending",
95
+ createdAt: event.createdAt,
96
+ };
97
+ for (const key of Object.keys(metadataShape)) {
98
+ row[key] = p[key];
99
+ }
100
+ await ctx.set(p._id, row as any);
101
+ },
102
+ )
103
+ .publicEvent("fileUploaded", { _id: fileId }, async (ctx, event) => {
104
+ await ctx.modify(event.payload._id, {
105
+ status: "uploaded",
106
+ uploadedAt: event.createdAt,
107
+ });
108
+ })
109
+ .publicEvent("fileFailed", { _id: fileId }, async (ctx, event) => {
110
+ await ctx.modify(event.payload._id, { status: "failed" });
111
+ })
112
+ .publicEvent(
113
+ "providerFileIdBound",
114
+ { _id: fileId, providerFileIdsJson: string() },
115
+ async (ctx, event) => {
116
+ await ctx.modify(event.payload._id, {
117
+ providerFileIdsJson: event.payload.providerFileIdsJson,
118
+ });
119
+ },
120
+ )
121
+ .publicEvent("fileRemoved", { _id: fileId }, async (ctx, event) => {
122
+ await ctx.remove(event.payload._id);
123
+ })
124
+
125
+ .mutateMethod("requestUpload", (fn) =>
126
+ fn
127
+ .withParams(requestUploadParams)
128
+ .withResult(
129
+ object({
130
+ uploadUrl: string(),
131
+ fileId: string(),
132
+ s3Key: string(),
133
+ }),
134
+ )
135
+ .handle(
136
+ ONLY_SERVER &&
137
+ (async (ctx, params) => {
138
+ const p = params as any;
139
+ // S3 path: `${aggregateName}/${fileId}/${filename}` — immutable.
140
+ // Identyfikatory scope (accountId/workspaceId) zostają w
141
+ // metadata fields, nie w path (zero copy on claim).
142
+ const s3Key = `${data.name}/${p._id}/${p.name}`;
143
+ const uploadUrl = await s3.presignUpload(s3Key, p.name && p.mime);
144
+
145
+ const payload: Record<string, any> = {
146
+ _id: p._id,
147
+ name: p.name,
148
+ mime: p.mime,
149
+ size: p.size,
150
+ s3Bucket: s3.bucket,
151
+ s3Key,
152
+ };
153
+ for (const key of Object.keys(metadataShape)) {
154
+ payload[key] = p[key];
155
+ }
156
+ await (ctx as any).fileRequested.emit(payload);
157
+
158
+ return {
159
+ uploadUrl,
160
+ fileId: String(p._id),
161
+ s3Key,
162
+ };
163
+ }),
164
+ ),
165
+ )
166
+
167
+ .mutateMethod("confirmUpload", (fn) =>
168
+ fn.withParams({ _id: fileId }).handle(
169
+ ONLY_SERVER &&
170
+ (async (ctx, params) => {
171
+ await ctx.fileUploaded.emit({ _id: params._id });
172
+ }),
173
+ ),
174
+ )
175
+
176
+ .mutateMethod("markFailed", (fn) =>
177
+ fn.withParams({ _id: fileId }).handle(
178
+ ONLY_SERVER &&
179
+ (async (ctx, params) => {
180
+ await ctx.fileFailed.emit({ _id: params._id });
181
+ }),
182
+ ),
183
+ )
184
+
185
+ .mutateMethod("bindProviderFileId", (fn) =>
186
+ fn
187
+ .withParams({
188
+ _id: fileId,
189
+ provider: string(),
190
+ providerFileId: string(),
191
+ })
192
+ .handle(
193
+ ONLY_SERVER &&
194
+ (async (ctx, params) => {
195
+ const existing = await ctx.$query.findOne({ _id: params._id });
196
+ if (!existing) return;
197
+ const current = parseProviderIds(
198
+ (existing as any).providerFileIdsJson,
199
+ );
200
+ if (
201
+ current[params.provider as FileProviderName] ===
202
+ params.providerFileId
203
+ ) {
204
+ return; // idempotent
205
+ }
206
+ current[params.provider as FileProviderName] =
207
+ params.providerFileId;
208
+ await ctx.providerFileIdBound.emit({
209
+ _id: params._id,
210
+ providerFileIdsJson: JSON.stringify(current),
211
+ });
212
+ }),
213
+ ),
214
+ )
215
+
216
+ .mutateMethod("remove", (fn) =>
217
+ fn.withParams({ _id: fileId }).handle(
218
+ ONLY_SERVER &&
219
+ (async (ctx, params) => {
220
+ const existing = await ctx.$query.findOne({ _id: params._id });
221
+ if (existing) {
222
+ try {
223
+ await s3.deleteObject((existing as any).s3Key as string);
224
+ } catch (err) {
225
+ console.warn("[arc-files] S3 deleteObject failed:", err);
226
+ }
227
+ }
228
+ await ctx.fileRemoved.emit({ _id: params._id });
229
+ }),
230
+ ),
231
+ );
232
+
233
+ // Chain protections — arc-core dispatchuje po tokenName.
234
+ // Cast do `any` w pętli — chain immutability w arc-core powoduje że literal
235
+ // tuple Events/MutateMethods rośnie z każdą iteracją, co przekracza TS limit.
236
+ let aggAny: any = agg;
237
+ for (const protection of data.protections) {
238
+ aggAny = aggAny.protectBy(protection.token, protection.check);
239
+ }
240
+
241
+ // Custom mutations z .withMutation() — każda dodaje:
242
+ // - publicEvent `<name>Performed` z default handler (modify(_id, patch))
243
+ // - mutateMethod `<name>` wrapping user handler z access do emitter
244
+ for (const mut of data.mutations) {
245
+ const eventName = `${mut.name}Performed`;
246
+ const eventPayload = { _id: fileId, ...mut.params };
247
+ aggAny = aggAny
248
+ .publicEvent(eventName, eventPayload, async (ctx: any, event: any) => {
249
+ const { _id, ...patch } = event.payload;
250
+ if (Object.keys(patch).length > 0) {
251
+ await ctx.modify(_id, patch);
252
+ }
253
+ })
254
+ .mutateMethod(mut.name, (fn: any) =>
255
+ fn.withParams({ _id: fileId, ...mut.params }).handle(
256
+ ONLY_SERVER &&
257
+ (async (ctx: any, params: any) => {
258
+ await mut.handler(ctx, params);
259
+ }),
260
+ ),
261
+ );
262
+ }
263
+
264
+ return aggAny
265
+ .clientQuery("getAll", (fn: any) =>
266
+ fn.handle(async (ctx: any) => ctx.$query.find({})),
267
+ )
268
+ .clientQuery("getById", (fn: any) =>
269
+ fn
270
+ .withParams({ _id: fileId })
271
+ .handle(async (ctx: any, params: any) =>
272
+ ctx.$query.findOne({ _id: params._id }),
273
+ ),
274
+ );
275
+ }
276
+
277
+ function parseProviderIds(
278
+ json: string | undefined,
279
+ ): Partial<Record<FileProviderName, string>> {
280
+ if (!json) return {};
281
+ try {
282
+ const parsed = JSON.parse(json);
283
+ return typeof parsed === "object" && parsed !== null ? parsed : {};
284
+ } catch {
285
+ return {};
286
+ }
287
+ }
288
+
289
+ export type FileTypeAggregate = ReturnType<typeof createFileTypeAggregate>;
@@ -0,0 +1,58 @@
1
+ /// <reference path="./arc.d.ts" />
2
+ import { createFileId } from "./ids/file";
3
+ import { ArcFileTypeBuilder, type DefaultFileTypeData } from "./file-type-builder";
4
+ import { S3Bridge } from "./s3/s3-client";
5
+ import type { FileDownloader, S3Config } from "./types";
6
+
7
+ /**
8
+ * Top-level builder dla arc-files. Tworzy shared `S3Bridge` + `fileId` brand
9
+ * dzielony przez wszystkie `fileType()` w obrębie tego buildera.
10
+ *
11
+ * const filesBuilder = arcFiles({ name: "ndt", s3: { ... } });
12
+ * const StrategyResearchFile = filesBuilder
13
+ * .fileType("strategyResearchFile")
14
+ * .withMetadata({ accountId, workspaceId: workspaceId.optional() })
15
+ * .protectedBy(userToken, (p) => ({ accountId: p.accountId }))
16
+ * .protectedBy(workspaceToken, (p) => ({ workspaceId: p.workspaceId }))
17
+ * .withMutation("claim", { workspaceId }, async (ctx, p) => {
18
+ * await ctx.claimPerformed.emit({ _id: p._id, workspaceId: p.workspaceId });
19
+ * })
20
+ * .build();
21
+ * const { fileId, s3, downloader } = filesBuilder.build();
22
+ */
23
+ export function arcFiles<const Name extends string>(config: {
24
+ name: Name;
25
+ s3: S3Config;
26
+ }) {
27
+ const s3 = new S3Bridge(config.s3);
28
+ const fileId = createFileId({ name: config.name });
29
+ const downloader: FileDownloader = {
30
+ download: (s3Key: string) => s3.downloadObject(s3Key),
31
+ };
32
+
33
+ return {
34
+ /**
35
+ * Rozpoczyna chain dla nowego file type. Nazwa staje się nazwą aggregate'u
36
+ * — np. `fileType("strategyResearchFile")` → `aggregate("strategyResearchFile", ...)`.
37
+ */
38
+ fileType<const TypeName extends string>(typeName: TypeName) {
39
+ return new ArcFileTypeBuilder<
40
+ DefaultFileTypeData & { name: TypeName }
41
+ >(
42
+ {
43
+ name: typeName,
44
+ metadata: {},
45
+ protections: [],
46
+ mutations: [],
47
+ } as DefaultFileTypeData & { name: TypeName },
48
+ s3,
49
+ fileId,
50
+ );
51
+ },
52
+
53
+ /** Shared resources — `fileId` brand, `s3` bridge, `downloader` dla openai adaptera. */
54
+ build() {
55
+ return { fileId, s3, downloader };
56
+ },
57
+ };
58
+ }
package/src/arc.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ declare const BROWSER: boolean;
2
+ declare const NOT_ON_BROWSER: boolean;
3
+ declare const ONLY_BROWSER: boolean;
4
+ declare const SERVER: boolean;
5
+ declare const NOT_ON_SERVER: boolean;
6
+ declare const ONLY_SERVER: boolean;
@@ -0,0 +1,153 @@
1
+ /// <reference path="./arc.d.ts" />
2
+ import type {
3
+ ArcId,
4
+ ArcRawShape,
5
+ ArcTokenAny,
6
+ Merge,
7
+ } from "@arcote.tech/arc";
8
+ import { createFileTypeAggregate } from "./aggregates/file";
9
+ import type { S3Bridge } from "./s3/s3-client";
10
+
11
+ /**
12
+ * Pojedyncza protection registered przez `.protectedBy()`. Aggregate
13
+ * chain'uje wszystkie protections — arc-core dispatchuje po `token.name`
14
+ * aktywnego tokena (semantyka OR po typie tokena).
15
+ */
16
+ export interface FileTypeProtection {
17
+ token: ArcTokenAny;
18
+ // `params` is the decoded token payload; user-provided check returns
19
+ // a where-clause restriction (`{field: value}`) or `false` to deny.
20
+ check: (params: any) => Record<string, unknown> | false;
21
+ }
22
+
23
+ /**
24
+ * Custom mutation rejestrowana przez `.withMutation()`. Builder generuje
25
+ * dedicated event `<name>Performed` + mutation method z params. Handler
26
+ * dostaje typed `ctx` z auto-generated event emitter.
27
+ */
28
+ export interface FileTypeMutation {
29
+ name: string;
30
+ params: ArcRawShape;
31
+ handler: (ctx: any, params: any) => Promise<void>;
32
+ }
33
+
34
+ /**
35
+ * State buildera — accumulated przez chain wywołań. Generic `<const Data>`
36
+ * preserved literal types (kluczowe dla `mergeUnsafe(builtIn, metadata)`
37
+ * i typed event payloads w `.attachFiles({File})`).
38
+ */
39
+ export interface FileTypeData {
40
+ name: string;
41
+ metadata: ArcRawShape;
42
+ protections: FileTypeProtection[];
43
+ mutations: FileTypeMutation[];
44
+ }
45
+
46
+ export type DefaultFileTypeData = {
47
+ name: string;
48
+ metadata: {};
49
+ protections: [];
50
+ mutations: [];
51
+ };
52
+
53
+ /**
54
+ * Chainable builder dla pojedynczego file type. Wzór: `ArcFunction` w
55
+ * `packages/core/src/context-element/function/arc-function.ts`.
56
+ *
57
+ * filesBuilder.fileType("strategyResearchFile")
58
+ * .withMetadata({ accountId, workspaceId: workspaceId.optional() })
59
+ * .protectedBy(userToken, (p) => ({ accountId: p.accountId }))
60
+ * .protectedBy(workspaceToken, (p) => ({ workspaceId: p.workspaceId }))
61
+ * .withMutation("claim", { workspaceId }, async (ctx, p) => {
62
+ * await ctx.claimPerformed.emit({ _id: p._id, workspaceId: p.workspaceId });
63
+ * })
64
+ * .build(); // → ArcAggregateElement
65
+ */
66
+ export class ArcFileTypeBuilder<
67
+ const Data extends FileTypeData = DefaultFileTypeData,
68
+ > {
69
+ readonly data: Data;
70
+ readonly #s3: S3Bridge;
71
+ readonly #fileId: ArcId<any>;
72
+
73
+ constructor(data: Data, s3: S3Bridge, fileId: ArcId<any>) {
74
+ this.data = data;
75
+ this.#s3 = s3;
76
+ this.#fileId = fileId;
77
+ }
78
+
79
+ /**
80
+ * Dodaje custom fields do aggregate schema. Generic `<const S extends ArcRawShape>`
81
+ * preserves literal types — fields są typed end-to-end w eventach/mutations/queries.
82
+ * Mergluje przez `mergeUnsafe(builtIn, metadata)` przy `.build()`.
83
+ */
84
+ withMetadata<const S extends ArcRawShape>(shape: S) {
85
+ return new ArcFileTypeBuilder<Merge<Data, { metadata: S }>>(
86
+ { ...this.data, metadata: shape } as any,
87
+ this.#s3,
88
+ this.#fileId,
89
+ );
90
+ }
91
+
92
+ /**
93
+ * Chain protection. Arc-core robi dispatch po `token.name` aktywnego
94
+ * tokena z `authAdapter.getDecoded().tokenName` — semantyka OR po typie
95
+ * tokena. Każdy `.protectedBy()` dodaje jeden entry do listy; przy
96
+ * `.build()` builder iteruje i woła `aggregate.protectBy()` per entry.
97
+ */
98
+ protectedBy<T extends ArcTokenAny>(
99
+ token: T,
100
+ check: (params: any) => Record<string, unknown> | false,
101
+ ) {
102
+ type NewProtections = [
103
+ ...Data["protections"],
104
+ { token: T; check: typeof check },
105
+ ];
106
+ return new ArcFileTypeBuilder<Merge<Data, { protections: NewProtections }>>(
107
+ {
108
+ ...this.data,
109
+ protections: [...this.data.protections, { token, check }],
110
+ } as any,
111
+ this.#s3,
112
+ this.#fileId,
113
+ );
114
+ }
115
+
116
+ /**
117
+ * Dodaje custom mutation z dedicated event `<name>Performed`. Handler
118
+ * dostaje `ctx` z auto-generated emitter dostępnym jako `ctx.<name>Performed.emit(...)`.
119
+ *
120
+ * Builder generuje:
121
+ * - publicEvent `<name>Performed` z payload = `{_id, ...params}`
122
+ * (default handler robi `ctx.modify(_id, patch)` — consumer może
123
+ * nadpisać przez emitting custom payload)
124
+ * - mutateMethod `<name>` z params = `{_id, ...params}` (ONLY_SERVER)
125
+ */
126
+ withMutation<const N extends string, const P extends ArcRawShape>(
127
+ name: N,
128
+ params: P,
129
+ handler: (ctx: any, params: any) => Promise<void>,
130
+ ) {
131
+ type NewMutations = [
132
+ ...Data["mutations"],
133
+ { name: N; params: P; handler: typeof handler },
134
+ ];
135
+ return new ArcFileTypeBuilder<Merge<Data, { mutations: NewMutations }>>(
136
+ {
137
+ ...this.data,
138
+ mutations: [...this.data.mutations, { name, params, handler }],
139
+ } as any,
140
+ this.#s3,
141
+ this.#fileId,
142
+ );
143
+ }
144
+
145
+ /**
146
+ * Finalizuje builder — zwraca konkretny `ArcAggregateElement` (z arc-core),
147
+ * gotowy do `context([...])` registration i do użycia w `.attachFiles({File})`,
148
+ * `ctx.query(File)`, `ctx.mutate(File)`.
149
+ */
150
+ build() {
151
+ return createFileTypeAggregate(this.data, this.#s3, this.#fileId);
152
+ }
153
+ }
@@ -0,0 +1,13 @@
1
+ import { id } from "@arcote.tech/arc";
2
+
3
+ export type FileIdData = {
4
+ name: string;
5
+ };
6
+
7
+ export const createFileId = <const Data extends FileIdData>(
8
+ data: Readonly<Data>,
9
+ ) => id(`${data.name}Id`);
10
+
11
+ export type FileId<Data extends FileIdData = FileIdData> = ReturnType<
12
+ typeof createFileId<Data>
13
+ >;
package/src/index.ts ADDED
@@ -0,0 +1,39 @@
1
+ // --- Builder API ---
2
+ export { arcFiles } from "./arc-files-builder";
3
+
4
+ // --- FileType builder ---
5
+ export { ArcFileTypeBuilder } from "./file-type-builder";
6
+ export type {
7
+ FileTypeData,
8
+ FileTypeProtection,
9
+ FileTypeMutation,
10
+ DefaultFileTypeData,
11
+ } from "./file-type-builder";
12
+
13
+ // --- Aggregate factory & type ---
14
+ export { createFileTypeAggregate } from "./aggregates/file";
15
+ export type { FileTypeAggregate } from "./aggregates/file";
16
+
17
+ // --- ID factory & type ---
18
+ export { createFileId } from "./ids/file";
19
+ export type { FileId, FileIdData } from "./ids/file";
20
+
21
+ // --- Types (shared with arc-ai adapters) ---
22
+ export type {
23
+ ArcFileRef,
24
+ BoundProviderFile,
25
+ FileDownloader,
26
+ FileProviderName,
27
+ S3Config,
28
+ } from "./types";
29
+
30
+ // --- S3 utility (escape hatch — consumers normalnie używają `arcFiles().s3`) ---
31
+ export { S3Bridge } from "./s3/s3-client";
32
+
33
+ // --- React picker ---
34
+ export { ArcFilePicker } from "./react/file-picker";
35
+ export type {
36
+ ArcFilePickerItem,
37
+ ArcFilePickerMutations,
38
+ ArcFilePickerProps,
39
+ } from "./react/file-picker";
@@ -0,0 +1,314 @@
1
+ import { Trans } from "@arcote.tech/platform";
2
+ import { Button } from "@arcote.tech/arc-ds";
3
+ import {
4
+ AlertCircle,
5
+ CheckCircle2,
6
+ FileText,
7
+ Loader2,
8
+ Paperclip,
9
+ X,
10
+ } from "lucide-react";
11
+ import { useRef, useState, type ReactNode } from "react";
12
+
13
+ /**
14
+ * Reprezentacja jednego pliku w listingu (pochodzi z `ArcFile` aggregate).
15
+ */
16
+ export interface ArcFilePickerItem {
17
+ fileId: string;
18
+ name: string;
19
+ size: number;
20
+ mime?: string;
21
+ status: "pending" | "uploaded" | "failed";
22
+ }
23
+
24
+ /**
25
+ * Mutations API które picker musi dostać z consumera (już zbindowane do
26
+ * skonkretyzowanego `File` aggregate'u w scope'ie).
27
+ */
28
+ export interface ArcFilePickerMutations {
29
+ requestUpload: (params: {
30
+ name: string;
31
+ mime: string;
32
+ size: number;
33
+ }) => Promise<{ uploadUrl: string; fileId: string; s3Key: string }>;
34
+ confirmUpload: (params: { fileId: string }) => Promise<void>;
35
+ markFailed: (params: { fileId: string }) => Promise<void>;
36
+ remove: (params: { fileId: string }) => Promise<void>;
37
+ }
38
+
39
+ export interface ArcFilePickerProps {
40
+ /** Reactive list of files (z `useQuery().<file>.getAll()` w consumerze) */
41
+ files: ArcFilePickerItem[];
42
+ /** Zbindowane mutations do tej scope'y */
43
+ mutations: ArcFilePickerMutations;
44
+ /** Akceptowane MIME (default: PDF/DOCX/MD/TXT/CSV) */
45
+ acceptedMime?: string;
46
+ /** Max size per file w bajtach (default 10 MB) */
47
+ maxBytes?: number;
48
+ /** Max ilość plików (default 5) */
49
+ maxFiles?: number;
50
+ /** Label sekcji (default "Pliki kontekstowe") */
51
+ label?: ReactNode;
52
+ /** Hint pod listą (default opisuje akceptowane formaty + limity) */
53
+ hint?: ReactNode;
54
+ /** Pozwala na wiele plików w jednym wyborze (default true) */
55
+ multiple?: boolean;
56
+ }
57
+
58
+ // Akceptacja przez file dialog — mix mime + extensions. Niektóre browsery
59
+ // zwracają nieintuicyjne `file.type` (np. CSV z Excelem → `application/vnd.ms-excel`,
60
+ // MD bez OS mapping → ""). Lista rozszerzeń jako fallback łapie te przypadki
61
+ // bo browser dopasowuje **albo** mime **albo** extension.
62
+ const DEFAULT_ACCEPTED =
63
+ [
64
+ // MIME types
65
+ "application/pdf",
66
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
67
+ "application/msword",
68
+ "application/vnd.ms-excel",
69
+ "text/markdown",
70
+ "text/plain",
71
+ "text/csv",
72
+ // Extensions (fallback gdy browser nie daje znanego mime)
73
+ ".pdf",
74
+ ".doc",
75
+ ".docx",
76
+ ".md",
77
+ ".markdown",
78
+ ".txt",
79
+ ".csv",
80
+ ].join(",");
81
+
82
+ /**
83
+ * Generyczny dropzone/picker plików dla arc-files. Consumer dostarcza
84
+ * `files` (z reactive query) i `mutations` (z scope'd model). Picker tylko
85
+ * orchestruje UX: walidacja limitów, PUT do S3, callback do markFailed.
86
+ */
87
+ export function ArcFilePicker({
88
+ files,
89
+ mutations,
90
+ acceptedMime = DEFAULT_ACCEPTED,
91
+ maxBytes = 10 * 1024 * 1024,
92
+ maxFiles = 5,
93
+ label,
94
+ hint,
95
+ multiple = true,
96
+ }: ArcFilePickerProps) {
97
+ const [uploading, setUploading] = useState(false);
98
+ const [error, setError] = useState<string | null>(null);
99
+ const inputRef = useRef<HTMLInputElement>(null);
100
+
101
+ const atCapacity = files.length >= maxFiles;
102
+ const maxMB = Math.round(maxBytes / (1024 * 1024));
103
+
104
+ const handleFiles = async (fileList: FileList | null) => {
105
+ if (!fileList || fileList.length === 0) return;
106
+ setError(null);
107
+
108
+ const accepted: File[] = [];
109
+ for (const file of Array.from(fileList)) {
110
+ if (files.length + accepted.length >= maxFiles) {
111
+ setError(`Maksymalnie ${maxFiles} plików — pomijam resztę.`);
112
+ break;
113
+ }
114
+ if (file.size > maxBytes) {
115
+ setError(`Plik ${file.name} jest większy niż ${maxMB} MB — pomijam.`);
116
+ continue;
117
+ }
118
+ accepted.push(file);
119
+ }
120
+
121
+ if (accepted.length === 0) return;
122
+ setUploading(true);
123
+ try {
124
+ for (const file of accepted) {
125
+ // Browser czasem zwraca nieintuicyjne MIME (np. CSV z Excelem →
126
+ // `application/vnd.ms-excel`, MD → "", PDF z drag terminal → "").
127
+ // Sniff z extension jest **bardziej wiarygodny** — używamy go zawsze
128
+ // gdy zwraca znany typ, fallback do `file.type` tylko gdy extension
129
+ // nieznane. OpenAI Files API odrzuca generic mime dla `purpose=user_data`.
130
+ const sniffed = inferMimeFromName(file.name);
131
+ const mime =
132
+ sniffed !== "application/octet-stream"
133
+ ? sniffed
134
+ : file.type || "application/octet-stream";
135
+ const { uploadUrl, fileId } = await mutations.requestUpload({
136
+ name: file.name,
137
+ mime,
138
+ size: file.size,
139
+ });
140
+ try {
141
+ const putResp = await fetch(uploadUrl, {
142
+ method: "PUT",
143
+ headers: { "Content-Type": mime },
144
+ body: file,
145
+ });
146
+ if (!putResp.ok) {
147
+ await mutations.markFailed({ fileId });
148
+ throw new Error(
149
+ `Upload failed for ${file.name}: HTTP ${putResp.status}`,
150
+ );
151
+ }
152
+ await mutations.confirmUpload({ fileId });
153
+ } catch (err) {
154
+ await mutations.markFailed({ fileId }).catch(() => {});
155
+ throw err;
156
+ }
157
+ }
158
+ } catch (e) {
159
+ setError(e instanceof Error ? e.message : String(e));
160
+ } finally {
161
+ setUploading(false);
162
+ if (inputRef.current) inputRef.current.value = "";
163
+ }
164
+ };
165
+
166
+ const handleRemove = async (fileId: string) => {
167
+ await mutations.remove({ fileId });
168
+ };
169
+
170
+ return (
171
+ <div className="space-y-3">
172
+ <div className="flex items-center justify-between">
173
+ <p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
174
+ {label ?? <Trans>Pliki</Trans>}{" "}
175
+ <span className="text-muted-foreground/60 normal-case">
176
+ ({files.length}/{maxFiles})
177
+ </span>
178
+ </p>
179
+ <Button
180
+ icon={Paperclip}
181
+ variant="ghost"
182
+ size="sm"
183
+ label={<Trans>Dodaj plik</Trans>}
184
+ disabled={atCapacity || uploading}
185
+ onClick={() => inputRef.current?.click()}
186
+ />
187
+ <input
188
+ ref={inputRef}
189
+ type="file"
190
+ multiple={multiple}
191
+ accept={acceptedMime}
192
+ className="hidden"
193
+ onChange={(e) => handleFiles(e.target.files)}
194
+ />
195
+ </div>
196
+
197
+ {hint && <p className="text-xs text-muted-foreground">{hint}</p>}
198
+
199
+ {files.length > 0 && (
200
+ <div className="flex flex-col gap-2">
201
+ {files.map((f) => (
202
+ <FileRow
203
+ key={f.fileId}
204
+ file={f}
205
+ onRemove={() => handleRemove(f.fileId)}
206
+ />
207
+ ))}
208
+ </div>
209
+ )}
210
+
211
+ {uploading && (
212
+ <p className="text-xs text-muted-foreground flex items-center gap-2">
213
+ <Loader2 className="h-3 w-3 animate-spin" />
214
+ <Trans>Wysyłam plik...</Trans>
215
+ </p>
216
+ )}
217
+
218
+ {error && (
219
+ <div className="rounded-lg border border-destructive/40 bg-destructive/5 p-2 text-xs text-destructive flex items-start gap-2">
220
+ <AlertCircle className="h-3.5 w-3.5 shrink-0 mt-0.5" />
221
+ <span>{error}</span>
222
+ </div>
223
+ )}
224
+ </div>
225
+ );
226
+ }
227
+
228
+ function FileRow({
229
+ file,
230
+ onRemove,
231
+ }: {
232
+ file: ArcFilePickerItem;
233
+ onRemove: () => void;
234
+ }) {
235
+ const sizeKB = file.size > 0 ? Math.round(file.size / 1024) : 0;
236
+ return (
237
+ <div className="flex items-center gap-3 rounded-lg border border-border/60 bg-background/50 px-3 py-2">
238
+ <FileText className="h-4 w-4 text-muted-foreground shrink-0" />
239
+ <div className="flex-1 min-w-0">
240
+ <p className="text-sm font-medium truncate">{file.name}</p>
241
+ <p className="text-xs text-muted-foreground flex items-center gap-1.5">
242
+ {sizeKB > 0 && <span>{sizeKB} KB</span>}
243
+ <StatusBadge status={file.status} />
244
+ </p>
245
+ </div>
246
+ <button
247
+ type="button"
248
+ onClick={onRemove}
249
+ className="rounded-full p-1.5 hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
250
+ aria-label="Usuń plik"
251
+ >
252
+ <X className="h-3.5 w-3.5" />
253
+ </button>
254
+ </div>
255
+ );
256
+ }
257
+
258
+ function StatusBadge({ status }: { status: "pending" | "uploaded" | "failed" }) {
259
+ switch (status) {
260
+ case "pending":
261
+ return (
262
+ <span className="inline-flex items-center gap-1 text-muted-foreground">
263
+ <Loader2 className="h-3 w-3 animate-spin" />
264
+ <Trans>Wysyłanie...</Trans>
265
+ </span>
266
+ );
267
+ case "uploaded":
268
+ return (
269
+ <span className="inline-flex items-center gap-1 text-green-600">
270
+ <CheckCircle2 className="h-3 w-3" />
271
+ <Trans>Gotowe</Trans>
272
+ </span>
273
+ );
274
+ case "failed":
275
+ return (
276
+ <span className="inline-flex items-center gap-1 text-destructive">
277
+ <AlertCircle className="h-3 w-3" />
278
+ <Trans>Błąd</Trans>
279
+ </span>
280
+ );
281
+ }
282
+ }
283
+
284
+ /**
285
+ * Sniffuje MIME z file extension. Browser czasem zwraca pusty `file.type`
286
+ * (np. drag z terminala, system bez extension mappings) — OpenAI Files API
287
+ * wymaga znanego mime dla `purpose=user_data`. Lista pokrywa typy
288
+ * akceptowane przez nasz default `ACCEPTED_MIME` (PDF/DOCX/MD/TXT/CSV).
289
+ */
290
+ function inferMimeFromName(name: string): string {
291
+ const ext = name.toLowerCase().split(".").pop() ?? "";
292
+ switch (ext) {
293
+ case "pdf":
294
+ return "application/pdf";
295
+ case "docx":
296
+ return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
297
+ case "doc":
298
+ return "application/msword";
299
+ case "md":
300
+ case "markdown":
301
+ return "text/markdown";
302
+ case "txt":
303
+ return "text/plain";
304
+ case "csv":
305
+ return "text/csv";
306
+ case "json":
307
+ return "application/json";
308
+ case "html":
309
+ case "htm":
310
+ return "text/html";
311
+ default:
312
+ return "application/octet-stream";
313
+ }
314
+ }
@@ -0,0 +1,118 @@
1
+ /// <reference path="../arc.d.ts" />
2
+ import type { S3Config } from "../types";
3
+
4
+ /**
5
+ * Server-side S3 client wrapper z **lazy aws-sdk import**.
6
+ *
7
+ * `@aws-sdk/client-s3` + `@aws-sdk/s3-request-presigner` to ~10 MB minified.
8
+ * Włączane do bundle przez statyczny import inflate'owały każdy consumer
9
+ * package (content.js: 340 MB, strategy.js: 84 MB, files.js: 41 MB).
10
+ *
11
+ * Po refactor: aws-sdk importowane przez `await import(...)` przy pierwszym
12
+ * S3 call. Konstruktor tylko zapamiętuje config (zero cost). Tree-shake
13
+ * bundlera wyrzuca aws-sdk z main chunks → server startup nie czeka na
14
+ * parsing 10 MB SDK. Pierwsze użycie ma ~100-200 ms overhead lazy load
15
+ * (acceptable dla async S3 operation).
16
+ *
17
+ * Cały moduł guard'owany przez `ONLY_SERVER` — w browser bundle bundler
18
+ * tree-shake'uje konstruktor (nigdy nie istnieje), więc browser nie widzi
19
+ * aws-sdk w ogóle.
20
+ */
21
+ export class S3Bridge {
22
+ readonly bucket: string;
23
+ readonly #config: S3Config;
24
+ #clientPromise: Promise<S3ClientLike> | null = null;
25
+
26
+ constructor(config: S3Config) {
27
+ this.bucket = config.bucket;
28
+ this.#config = config;
29
+ }
30
+
31
+ /**
32
+ * Lazy client construction. Pierwszy call odpala dynamic import aws-sdk
33
+ * + `new S3Client(...)`; kolejne calle reużywają cached Promise.
34
+ */
35
+ async #client(): Promise<S3ClientLike> {
36
+ if (!ONLY_SERVER) {
37
+ throw new Error("S3Bridge not available in browser context");
38
+ }
39
+ if (!this.#clientPromise) {
40
+ this.#clientPromise = (async () => {
41
+ const { S3Client } = await import("@aws-sdk/client-s3");
42
+ return new S3Client({
43
+ region: this.#config.region,
44
+ endpoint: this.#config.endpoint,
45
+ credentials: {
46
+ accessKeyId: this.#config.accessKeyId,
47
+ secretAccessKey: this.#config.secretAccessKey,
48
+ },
49
+ forcePathStyle:
50
+ this.#config.forcePathStyle ?? Boolean(this.#config.endpoint),
51
+ }) as S3ClientLike;
52
+ })();
53
+ }
54
+ return this.#clientPromise;
55
+ }
56
+
57
+ async presignUpload(key: string, mime: string, expiresIn = 300) {
58
+ const [client, { PutObjectCommand }, { getSignedUrl }] = await Promise.all([
59
+ this.#client(),
60
+ import("@aws-sdk/client-s3"),
61
+ import("@aws-sdk/s3-request-presigner"),
62
+ ]);
63
+ return getSignedUrl(
64
+ client as any,
65
+ new PutObjectCommand({
66
+ Bucket: this.bucket,
67
+ Key: key,
68
+ ContentType: mime,
69
+ }),
70
+ { expiresIn },
71
+ );
72
+ }
73
+
74
+ async presignDownload(key: string, expiresIn = 3600) {
75
+ const [client, { GetObjectCommand }, { getSignedUrl }] = await Promise.all([
76
+ this.#client(),
77
+ import("@aws-sdk/client-s3"),
78
+ import("@aws-sdk/s3-request-presigner"),
79
+ ]);
80
+ return getSignedUrl(
81
+ client as any,
82
+ new GetObjectCommand({ Bucket: this.bucket, Key: key }),
83
+ { expiresIn },
84
+ );
85
+ }
86
+
87
+ async downloadObject(key: string): Promise<Buffer> {
88
+ const [client, { GetObjectCommand }] = await Promise.all([
89
+ this.#client(),
90
+ import("@aws-sdk/client-s3"),
91
+ ]);
92
+ const out = await client.send(
93
+ new GetObjectCommand({ Bucket: this.bucket, Key: key }),
94
+ );
95
+ const body = out.Body;
96
+ if (!body) throw new Error(`Empty S3 body for key: ${key}`);
97
+ const bytes = await (body as any).transformToByteArray();
98
+ return Buffer.from(bytes);
99
+ }
100
+
101
+ async deleteObject(key: string): Promise<void> {
102
+ const [client, { DeleteObjectCommand }] = await Promise.all([
103
+ this.#client(),
104
+ import("@aws-sdk/client-s3"),
105
+ ]);
106
+ await client.send(
107
+ new DeleteObjectCommand({ Bucket: this.bucket, Key: key }),
108
+ );
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Minimalny interfejs `S3Client.send()` którego potrzebujemy. Unika
114
+ * importu typu z aws-sdk na poziomie public API (tree-shake friendly).
115
+ */
116
+ interface S3ClientLike {
117
+ send(command: any): Promise<any>;
118
+ }
package/src/types.ts ADDED
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Provider name for file_id binding (OpenAI / Claude / Gemini).
3
+ */
4
+ export type FileProviderName = "openai" | "claude" | "gemini";
5
+
6
+ /**
7
+ * Reference do pliku przekazywanego do LLM providera.
8
+ *
9
+ * Powstaje z `ArcFile` record + lazy uzupełnianego `providerFileIds`.
10
+ * Adapter providera czyta `providerFileIds[<name>]` — jeśli puste, robi
11
+ * lazy upload (przez wstrzyknięty `FileDownloader`) i zwraca binding w
12
+ * `CompletionResult.boundProviderFiles[]`. Consumer (ai-generation-listener)
13
+ * iteruje boundy i woła `File.bindProviderFileId(...)` per scope.
14
+ */
15
+ export type ArcFileRef = {
16
+ fileId: string;
17
+ name: string;
18
+ mime: string;
19
+ s3Key: string;
20
+ providerFileIds?: Partial<Record<FileProviderName, string>>;
21
+ };
22
+
23
+ /**
24
+ * Pojedyncze binding wynikłe z lazy upload — adapter zwraca tablicę
25
+ * w `CompletionResult.boundProviderFiles`.
26
+ */
27
+ export type BoundProviderFile = {
28
+ fileId: string;
29
+ provider: FileProviderName;
30
+ providerFileId: string;
31
+ };
32
+
33
+ /**
34
+ * Minimalny S3 utility wstrzykiwany w adapter providera. Adapter używa go
35
+ * do `download(s3Key) → Buffer`. Adapter nie zna aggregate'u — nie ma
36
+ * dependency na arc-files w arc-ai.
37
+ */
38
+ export interface FileDownloader {
39
+ download(s3Key: string): Promise<Buffer>;
40
+ }
41
+
42
+ /**
43
+ * S3 config dla fragmentu — przekazywany w `arcFiles({s3: ...})`.
44
+ * `endpoint` opcjonalny (MinIO / R2 / inne S3-compatible).
45
+ */
46
+ export type S3Config = {
47
+ bucket: string;
48
+ region: string;
49
+ accessKeyId: string;
50
+ secretAccessKey: string;
51
+ endpoint?: string;
52
+ forcePathStyle?: boolean;
53
+ };
package/tsconfig.json ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "extends": "../../../tsconfig.json",
3
+ "include": ["src/**/*"]
4
+ }