feat: v0.4.0 — File sharing + multi-target messages
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Release / Publish multi-arch images (push) Has been cancelled

Files: MinIO-backed file sharing built into the broker.
share_file for persistent mesh files, send_message(file:) for
ephemeral attachments. Presigned URLs for download, access
tracking per peer.

Broker infra: MinIO in docker-compose, internal network.
HTTP POST /upload endpoint. WS handlers for get_file,
list_files, file_status, delete_file.

Multi-target: send_message(to:) accepts string or array.
Targets deduplicated before delivery.

Targeted views: MCP instructions teach Claude to send
tailored messages per audience instead of generic broadcasts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-04-06 13:56:01 +01:00
parent 99d9d19079
commit 1aaa483d60
16 changed files with 4187 additions and 21 deletions

View File

@@ -0,0 +1,28 @@
CREATE TABLE "mesh"."file" (
"id" text PRIMARY KEY NOT NULL,
"mesh_id" text NOT NULL,
"name" text NOT NULL,
"size_bytes" integer NOT NULL,
"mime_type" text,
"minio_key" text NOT NULL,
"tags" text[] DEFAULT '{}',
"persistent" boolean DEFAULT true NOT NULL,
"uploaded_by_name" text,
"uploaded_by_member" text,
"target_spec" text,
"uploaded_at" timestamp DEFAULT now() NOT NULL,
"expires_at" timestamp,
"deleted_at" timestamp
);
--> statement-breakpoint
CREATE TABLE "mesh"."file_access" (
"id" text PRIMARY KEY NOT NULL,
"file_id" text NOT NULL,
"peer_session_pubkey" text,
"peer_name" text,
"accessed_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "mesh"."file" ADD CONSTRAINT "file_mesh_id_mesh_id_fk" FOREIGN KEY ("mesh_id") REFERENCES "mesh"."mesh"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "mesh"."file" ADD CONSTRAINT "file_uploaded_by_member_member_id_fk" FOREIGN KEY ("uploaded_by_member") REFERENCES "mesh"."member"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "mesh"."file_access" ADD CONSTRAINT "file_access_file_id_file_id_fk" FOREIGN KEY ("file_id") REFERENCES "mesh"."file"("id") ON DELETE cascade ON UPDATE no action;

File diff suppressed because it is too large Load Diff

View File

@@ -64,6 +64,13 @@
"when": 1775477883426,
"tag": "0008_add-state-and-memory",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1775480008546,
"tag": "0009_add-file-tables",
"breakpoints": true
}
]
}

View File

@@ -1,5 +1,6 @@
import { relations } from "drizzle-orm";
import {
boolean,
integer,
jsonb,
pgSchema,
@@ -289,6 +290,43 @@ export const meshMemory = meshSchema.table("memory", {
forgottenAt: timestamp(),
});
/**
* File metadata for shared files in a mesh. Actual bytes live in MinIO;
* this table tracks ownership, access control, and soft-deletion.
*/
export const meshFile = meshSchema.table("file", {
id: text().primaryKey().notNull().$defaultFn(generateId),
meshId: text()
.references(() => mesh.id, { onDelete: "cascade", onUpdate: "cascade" })
.notNull(),
name: text().notNull(),
sizeBytes: integer().notNull(),
mimeType: text(),
minioKey: text().notNull(),
tags: text().array().default([]),
persistent: boolean().notNull().default(true),
uploadedByName: text(),
uploadedByMember: text().references(() => meshMember.id),
targetSpec: text(), // null = entire mesh
uploadedAt: timestamp().defaultNow().notNull(),
expiresAt: timestamp(),
deletedAt: timestamp(),
});
/**
* Access log for file downloads. Tracks which peer accessed which file
* and when, for auditability and read-receipt semantics.
*/
export const meshFileAccess = meshSchema.table("file_access", {
id: text().primaryKey().notNull().$defaultFn(generateId),
fileId: text()
.references(() => meshFile.id, { onDelete: "cascade" })
.notNull(),
peerSessionPubkey: text(),
peerName: text(),
accessedAt: timestamp().defaultNow().notNull(),
});
export const meshRelations = relations(mesh, ({ one, many }) => ({
owner: one(user, {
fields: [mesh.ownerUserId],
@@ -367,6 +405,25 @@ export const meshMemoryRelations = relations(meshMemory, ({ one }) => ({
}),
}));
export const meshFileRelations = relations(meshFile, ({ one, many }) => ({
mesh: one(mesh, {
fields: [meshFile.meshId],
references: [mesh.id],
}),
uploader: one(meshMember, {
fields: [meshFile.uploadedByMember],
references: [meshMember.id],
}),
accesses: many(meshFileAccess),
}));
export const meshFileAccessRelations = relations(meshFileAccess, ({ one }) => ({
file: one(meshFile, {
fields: [meshFileAccess.fileId],
references: [meshFile.id],
}),
}));
export const selectMeshSchema = createSelectSchema(mesh);
export const insertMeshSchema = createInsertSchema(mesh);
export const selectMemberSchema = createSelectSchema(meshMember);
@@ -404,3 +461,11 @@ export type SelectMeshState = typeof meshState.$inferSelect;
export type InsertMeshState = typeof meshState.$inferInsert;
export type SelectMeshMemory = typeof meshMemory.$inferSelect;
export type InsertMeshMemory = typeof meshMemory.$inferInsert;
export const selectMeshFileSchema = createSelectSchema(meshFile);
export const insertMeshFileSchema = createInsertSchema(meshFile);
export const selectMeshFileAccessSchema = createSelectSchema(meshFileAccess);
export const insertMeshFileAccessSchema = createInsertSchema(meshFileAccess);
export type SelectMeshFile = typeof meshFile.$inferSelect;
export type InsertMeshFile = typeof meshFile.$inferInsert;
export type SelectMeshFileAccess = typeof meshFileAccess.$inferSelect;
export type InsertMeshFileAccess = typeof meshFileAccess.$inferInsert;