* Extension host ops: tags.add and tags.remove over the library's tag ops An extension with the write_tags grant can tag a record or a content identity by tag path, creating the tag by name through tags.create when nothing defines it yet, and remove it again through tags.unapply. records.query honors a tag filter instead of refusing it. Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com> * Extension host ops: records.set_field and records.get_field Custom fields live in the extension's own store, in a built-in custom_field model every extension schema carries, keyed by record uuid, namespace and name. A write needs the namespace in the manifest's write_custom_fields; a read is the extension's own data. The source store schema is untouched. Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com> * Extension host op: jobs.dispatch queues one of the extension's own jobs An extension with the dispatch_jobs grant can queue another of its own jobs by registered name with a starting state. The name resolves under the caller's extension id, so no extension can start another's job. The SDK's JobDispatcher goes through it and returns the job id. Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com> * Photos: create_moments runs end to end from EXIF, places and scenes do their non-inference parts A records.exif host op (read_records) answers a record's capture time, GPS and camera: from the store's image facet when an ingest wrote one, otherwise parsed from the file through sd-media-metadata, since no ingest writes the facet yet. create_moments groups photos by time gap and distance into Moment models, tags each photo Moments/<title> and sets photos.moment_id, which also keeps a re-run from grouping twice; titles come from the dates. identify_places clusters by GPS, matches or creates Place models, tags and sets photos.place_id, and keeps a place unnamed when the reverse geocode's language model is not available. analyze_scenes skips with one warning without a classifier and tags from an existing scene sidecar. sd-store: a bare string stored in a JSON column reads back as that string instead of null, which a uuid alias field in a model hit. Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com> * WASM acceptance: tags, fields, dispatch and photos moments over EXIF-dated JPEGs The test extension's tag job tags by name, sets and reads a custom field, is refused an ungranted namespace and queues a counter job; the same module under a manifest without the grants fails at the first tag. A moments fixture of twelve hand-built EXIF JPEGs (dates and GPS) yields three moments, places from GPS, and the not_available path for scenes. Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com> * Docs: extension host functions phase 2 in the SDK reference, the extension README and PROJECT_STATUS Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com> * cargo fmt the extension crates Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com> * Review fixes: tag-filtered queries start from the tagged records, identify_places skips placed photos A tag filter now resolves each tagged record through its store and applies scope and extension there, so it is not bounded by the walk cap, and a store that cannot list its tagged records is an error rather than an empty match. identify_places leaves a photo that already carries photos.place_id alone, so a re-run places only new photos and a place's photo_count stays the number of photos assigned to it. Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com> * Tag-filtered record queries exclude hidden files like the walk does Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>
Spacedrive Official Extensions
This directory contains the extension SDK and official extensions for Spacedrive.
Structure
extensions/
├── spacedrive-sdk/ # Core SDK library
├── spacedrive-sdk-macros/ # Proc macros for beautiful API
├── test-extension/ # Example extension with beautiful API
└── finance/ # (Future) First revenue-generating extension
Quick Start
1. Install WASM Target
rustup target add wasm32-unknown-unknown
2. Create Extension
cargo new --lib my-extension
cd my-extension
Cargo.toml:
[lib]
crate-type = ["cdylib"]
[dependencies]
spacedrive-sdk = { path = "../spacedrive-sdk" }
serde = { version = "1.0", features = ["derive"] }
src/lib.rs:
use spacedrive_sdk::prelude::*;
use spacedrive_sdk::{extension, job};
#[extension(
id = "my-extension",
name = "My Extension",
version = "0.1.0"
)]
struct MyExtension;
#[derive(Serialize, Deserialize, Default)]
pub struct MyJobState {
pub counter: u32,
}
#[job]
fn my_job(ctx: &JobContext, state: &mut MyJobState) -> Result<()> {
ctx.log("Job starting!");
state.counter += 1;
ctx.report_progress(1.0, "Done!");
Ok(())
}
3. Build
cargo build --target wasm32-unknown-unknown --release
cp target/wasm32-unknown-unknown/release/my_extension.wasm .
4. Create manifest.json
{
"id": "my-extension",
"name": "My Extension",
"version": "0.1.0",
"wasm_file": "my_extension.wasm",
"permissions": {
"read_records": { "glob": "**/*.{jpg,png}" },
"read_sidecars": ["exif"],
"write_sidecars": ["digest"],
"use_models": [{ "category": "face_detection", "preference": "local" }]
}
}
The host rejects a manifest with a field it does not know, so a typo in a
grant fails at load. read_records is the only way to see files; its optional
glob is honored for its trailing extension list only (*.jpg or
**/*.{jpg,png}); the directory part is ignored and a glob of another shape
is refused at load. write_sidecars names the kinds
the extension may write; use_models the inference categories it may ask for.
write_tags, write_custom_fields and dispatch_jobs are declared but have
no host side yet.
The Beautiful API
Before Macros (Manual FFI):
#[no_mangle]
pub extern "C" fn execute_my_job(
ctx_ptr: u32, ctx_len: u32,
state_ptr: u32, state_len: u32
) -> i32 {
let ctx_json = unsafe { /* 30 lines of pointer manipulation */ };
let mut state = /* 40 lines of deserialization */;
// ... business logic buried in boilerplate ...
}
180+ lines, lots of unsafe
After Macros (Beautiful):
#[job]
fn my_job(ctx: &JobContext, state: &mut MyJobState) -> Result<()> {
// Just write business logic!
ctx.log("Working...");
state.counter += 1;
Ok(())
}
60-80 lines, zero unsafe, pure logic
API Reference
Extension Container
#[extension(
id = "finance",
name = "Spacedrive Finance",
version = "0.1.0"
)]
struct Finance;
Generates:
plugin_init()exportplugin_cleanup()export- Metadata for manifest generation
Job Definition
#[job]
fn email_scan(ctx: &JobContext, state: &mut EmailScanState) -> Result<()> {
// Progress reporting
ctx.report_progress(0.5, "Half done");
// Checkpointing
ctx.checkpoint(state)?;
// Interruption handling
if ctx.check_interrupt() {
return Err(Error::OperationFailed("Interrupted".into()));
}
// Metrics
ctx.increment_items(1);
ctx.increment_bytes(1000);
// Warnings
ctx.add_warning("Non-fatal issue");
// Full SDK access
let record = ctx.vdfs().get_record(uuid).await?;
let bytes = record.read().await?;
Ok(())
}
VDFS Operations
// Records of the library's sources, filtered by extension (read_records grant)
let photos = ctx.vdfs()
.query_records()
.with_extensions(["jpg", "png"])
.collect()
.await?;
// One record and its bytes
let record = ctx.vdfs().get_record(uuid).await?;
let bytes = record.read().await?;
// Sidecars are one JSON document per kind, keyed by content uuid
let content_uuid = record.content_uuid().expect("hashed");
if !ctx.sidecar_exists(content_uuid, "digest")? {
ctx.save_sidecar(content_uuid, "digest", &digest).await?;
}
let digest: Digest = ctx.read_sidecar(content_uuid, "digest").await?;
// Models declared with #[model] live in the extension's own store
ctx.vdfs().create_model(entry).await?;
let all: Vec<CatalogEntry> = ctx.vdfs().query_models().collect().await?;
Tags and custom fields have no host side yet and return Error::NotAvailable.
AI Operations
Inference goes through ctx.ai().infer(...) behind the use_models grant.
The core has no provider yet, so every request answers Error::NotAvailable;
handle it the way extensions/photos/src/jobs/analyze.rs does, with one
warning and a skip, so the job completes and a later run with a provider
picks the files up.
// Written against the API; answers NotAvailable until a provider exists
let faces = ctx.ai().from_registered("face_detection").detect_faces(&bytes).await?;
Credentials
// Store OAuth
ctx.credentials().store("gmail", Credential::oauth2(
access_token,
Some(refresh_token),
3600,
vec!["https://www.googleapis.com/auth/gmail.readonly".into()]
))?;
// Get (auto-refreshes)
let cred = ctx.credentials().get("gmail")?;
Examples
See extensions/test-extension/ for a complete working example.
Building
All extensions:
cd extensions/test-extension
cargo build --target wasm32-unknown-unknown --release
Documentation
- SDK API Vision - Future API improvements
- Before/After Comparison - See the transformation
- WASM Architecture - Technical details
- Platform Revenue Model - Business case
Extension development is now beautiful, safe, and productive. Start building!