feat(v1.1.5): files SDK + files:* triggers
Filesystem-backed blob storage as the fifth concrete trigger kind.
- `files::collection(c).{create,head,get,update,delete,list}` Rhai SDK
(blob in/out; metadata maps; missing-field throws naming the field).
- `FilesService` trait in picloud-shared; `FsFilesRepo` (atomic
write: temp→fsync→rename→fsync-dir→DB; single-pass SHA-256;
checksum-verified reads → Corrupted) + `FilesServiceImpl` in
manager-core. Metadata in Postgres (0018), bytes on disk under
PICLOUD_FILES_ROOT with 0o700 shard dirs.
- `files:*` trigger kind via the Layout-E pattern (0019: widen both
CHECKs + files_trigger_details), TriggerEvent::Files (metadata only,
no bytes), emit_files fan-out, dispatcher arm, admin endpoint
POST /triggers/files (reuses validate_trigger_target).
- AppFilesRead/AppFilesWrite capabilities → script:read/script:write
(seven-scope commitment held). AppPubsubPublish reserved for v1.1.6.
- Admin files API (list + delete) + dashboard Files view per app.
Cross-app isolation keyed on cx.app_id at every layer. ~45 new tests
(service in-memory, fs tempdir, bridge integration). No DB required
for the suite. publish_ephemeral and the orphan sweep stay deferred.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -211,7 +211,7 @@ export interface DeadLetterRow {
|
||||
resolution: 'replayed' | 'ignored' | 'handled_by_script' | 'handler_failed' | null;
|
||||
}
|
||||
|
||||
export type TriggerKind = 'kv' | 'docs' | 'dead_letter' | 'cron';
|
||||
export type TriggerKind = 'kv' | 'docs' | 'dead_letter' | 'cron' | 'files' | 'pubsub';
|
||||
export type TriggerDispatchMode = 'sync' | 'async';
|
||||
|
||||
/// Per-kind detail, tagged by `kind` to match the Rust serde shape.
|
||||
@@ -219,7 +219,21 @@ export type TriggerDetails =
|
||||
| { kind: 'kv'; collection_glob: string; ops: string[] }
|
||||
| { kind: 'docs'; collection_glob: string; ops: string[] }
|
||||
| { kind: 'dead_letter'; source_filter?: string; trigger_id_filter?: string; script_id_filter?: string }
|
||||
| { kind: 'cron'; schedule: string; timezone: string; last_fired_at?: string | null };
|
||||
| { kind: 'cron'; schedule: string; timezone: string; last_fired_at?: string | null }
|
||||
| { kind: 'files'; collection_glob: string; ops: string[] }
|
||||
| { kind: 'pubsub'; topic_pattern: string };
|
||||
|
||||
/// v1.1.5 file metadata as the admin files endpoint returns it.
|
||||
export interface FileMeta {
|
||||
id: string;
|
||||
collection: string;
|
||||
name: string;
|
||||
content_type: string;
|
||||
size: number;
|
||||
checksum: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Trigger {
|
||||
id: string;
|
||||
@@ -625,6 +639,23 @@ export const api = {
|
||||
)
|
||||
},
|
||||
|
||||
files: {
|
||||
list: (idOrSlug: string, collection: string, opts: { cursor?: string; limit?: number } = {}) => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('collection', collection);
|
||||
if (opts.cursor) params.set('cursor', opts.cursor);
|
||||
if (opts.limit !== undefined) params.set('limit', String(opts.limit));
|
||||
return adminRequest<{ files: FileMeta[]; next_cursor: string | null }>(
|
||||
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/files?${params.toString()}`
|
||||
);
|
||||
},
|
||||
remove: (idOrSlug: string, collection: string, fileId: string) =>
|
||||
adminRequest<null>(
|
||||
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/files/${encodeURIComponent(collection)}/${fileId}`,
|
||||
{ method: 'DELETE' }
|
||||
)
|
||||
},
|
||||
|
||||
execute: async (
|
||||
id: string,
|
||||
body: unknown,
|
||||
|
||||
@@ -530,6 +530,13 @@
|
||||
class:active={activeTab === 'settings'}
|
||||
onclick={() => (activeTab = 'settings')}>Settings</button
|
||||
>
|
||||
<a
|
||||
class="tab-link"
|
||||
href="{base}/apps/{slug}/files"
|
||||
title="Files — browse and delete stored blobs by collection"
|
||||
>
|
||||
Files
|
||||
</a>
|
||||
<a
|
||||
class="tab-link"
|
||||
href="{base}/apps/{slug}/dead-letters"
|
||||
|
||||
229
dashboard/src/routes/apps/[slug]/files/+page.svelte
Normal file
229
dashboard/src/routes/apps/[slug]/files/+page.svelte
Normal file
@@ -0,0 +1,229 @@
|
||||
<script lang="ts">
|
||||
import { base } from '$app/paths';
|
||||
import { page } from '$app/state';
|
||||
import { api, ApiError, type App, type FileMeta } from '$lib/api';
|
||||
import ConfirmModal from '$lib/ConfirmModal.svelte';
|
||||
|
||||
let slug = $derived(page.params.slug ?? '');
|
||||
let app = $state<App | null>(null);
|
||||
let collection = $state('');
|
||||
let activeCollection = $state('');
|
||||
let files = $state<FileMeta[]>([]);
|
||||
let nextCursor = $state<string | null>(null);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let fileToRemove = $state<FileMeta | null>(null);
|
||||
let removing = $state(false);
|
||||
|
||||
async function loadApp() {
|
||||
try {
|
||||
app = await api.apps.get(slug);
|
||||
} catch (e) {
|
||||
error = e instanceof ApiError ? e.message : String(e);
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
void slug;
|
||||
void loadApp();
|
||||
});
|
||||
|
||||
async function loadFiles(cursor?: string) {
|
||||
const c = collection.trim();
|
||||
if (!c) {
|
||||
error = 'Enter a collection name to list its files.';
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const res = await api.files.list(slug, c, { cursor, limit: 100 });
|
||||
if (cursor) {
|
||||
files = [...files, ...res.files];
|
||||
} else {
|
||||
files = res.files;
|
||||
activeCollection = c;
|
||||
}
|
||||
nextCursor = res.next_cursor;
|
||||
} catch (e) {
|
||||
error = e instanceof ApiError ? e.message : String(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRemove() {
|
||||
if (!fileToRemove) return;
|
||||
removing = true;
|
||||
try {
|
||||
await api.files.remove(slug, fileToRemove.collection, fileToRemove.id);
|
||||
files = files.filter((f) => f.id !== fileToRemove!.id);
|
||||
fileToRemove = null;
|
||||
} catch (e) {
|
||||
error = e instanceof ApiError ? e.message : String(e);
|
||||
} finally {
|
||||
removing = false;
|
||||
}
|
||||
}
|
||||
|
||||
function fmtTime(iso: string): string {
|
||||
return new Date(iso).toLocaleString();
|
||||
}
|
||||
|
||||
function fmtSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Files · {slug} · PiCloud</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="container">
|
||||
<header>
|
||||
<div>
|
||||
<a href="{base}/apps/{slug}" class="back">← back to {app?.name ?? slug}</a>
|
||||
<h1>Files</h1>
|
||||
<p class="subtitle">
|
||||
Browse and delete stored blobs by collection. Uploads happen from scripts via
|
||||
<code>files::collection(c).create(…)</code>.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form
|
||||
class="collection-form"
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void loadFiles();
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
<span>Collection</span>
|
||||
<input bind:value={collection} placeholder="avatars" required />
|
||||
</label>
|
||||
<button type="submit" disabled={loading || !collection.trim()}>
|
||||
{loading ? 'Loading…' : 'List files'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{#if error}
|
||||
<div class="error">{error}</div>
|
||||
{/if}
|
||||
|
||||
{#if activeCollection}
|
||||
{#if files.length === 0 && !loading}
|
||||
<p class="muted">No files in collection <code>{activeCollection}</code>.</p>
|
||||
{:else}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Content type</th>
|
||||
<th>Size</th>
|
||||
<th>Created</th>
|
||||
<th>ID</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each files as f (f.id)}
|
||||
<tr>
|
||||
<td>{f.name}</td>
|
||||
<td><code>{f.content_type}</code></td>
|
||||
<td>{fmtSize(f.size)}</td>
|
||||
<td>{fmtTime(f.created_at)}</td>
|
||||
<td class="mono small">{f.id}</td>
|
||||
<td>
|
||||
<button type="button" class="danger" onclick={() => (fileToRemove = f)}>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{#if nextCursor}
|
||||
<button type="button" class="secondary" onclick={() => loadFiles(nextCursor ?? undefined)}>
|
||||
Load more
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if fileToRemove}
|
||||
<ConfirmModal
|
||||
title="Delete file"
|
||||
variant="danger"
|
||||
confirmLabel="Delete file"
|
||||
onConfirm={confirmRemove}
|
||||
onCancel={() => (fileToRemove = null)}
|
||||
>
|
||||
<p>
|
||||
Delete <strong>{fileToRemove.name}</strong> ({fmtSize(fileToRemove.size)}) from collection
|
||||
<code>{fileToRemove.collection}</code>? This removes both the metadata row and the bytes on
|
||||
disk and cannot be undone.
|
||||
</p>
|
||||
{#if removing}<p class="muted">Deleting…</p>{/if}
|
||||
</ConfirmModal>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.container {
|
||||
max-width: 60rem;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
header {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.back {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.subtitle {
|
||||
color: var(--muted, #666);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.collection-form {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.collection-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
text-align: left;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-bottom: 1px solid var(--border, #e2e2e2);
|
||||
}
|
||||
.mono {
|
||||
font-family: monospace;
|
||||
}
|
||||
.small {
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.muted {
|
||||
color: var(--muted, #666);
|
||||
}
|
||||
.error {
|
||||
color: #b00020;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
button.danger {
|
||||
color: #b00020;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user