From 4eaeba9ee8ea6722569af18825351f04f8310853 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Sat, 30 May 2026 13:18:29 +0100 Subject: [PATCH 1/3] feat(store): create crate, scaffolding and add minimal glyph table --- Cargo.lock | 112 +++++++++++++++++++------ Cargo.toml | 1 + crates/shift-store/Cargo.toml | 11 +++ crates/shift-store/README.md | 40 +++++++++ crates/shift-store/src/connection.rs | 25 ++++++ crates/shift-store/src/error.rs | 5 ++ crates/shift-store/src/glyph.rs | 49 +++++++++++ crates/shift-store/src/lib.rs | 11 +++ crates/shift-store/src/schema.rs | 16 ++++ crates/shift-store/src/store.rs | 3 + crates/shift-store/src/types.rs | 38 +++++++++ crates/shift-store/tests/store_test.rs | 27 ++++++ 12 files changed, 314 insertions(+), 24 deletions(-) create mode 100644 crates/shift-store/Cargo.toml create mode 100644 crates/shift-store/README.md create mode 100644 crates/shift-store/src/connection.rs create mode 100644 crates/shift-store/src/error.rs create mode 100644 crates/shift-store/src/glyph.rs create mode 100644 crates/shift-store/src/lib.rs create mode 100644 crates/shift-store/src/schema.rs create mode 100644 crates/shift-store/src/store.rs create mode 100644 crates/shift-store/src/types.rs create mode 100644 crates/shift-store/tests/store_test.rs diff --git a/Cargo.lock b/Cargo.lock index 7e23b4e4..fefed768 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -499,6 +499,18 @@ dependencies = [ "num-traits", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.3.0" @@ -542,6 +554,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "font-types" version = "0.9.0" @@ -835,6 +853,18 @@ name = "hashbrown" version = "0.15.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown", +] [[package]] name = "heck" @@ -992,10 +1022,12 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.77" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] @@ -1062,6 +1094,16 @@ dependencies = [ "redox_syscall", ] +[[package]] +name = "libsqlite3-sys" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" +dependencies = [ + "pkg-config", + "vcpkg", +] + [[package]] name = "linesweeper" version = "0.3.0" @@ -1310,6 +1352,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + [[package]] name = "plist" version = "1.7.2" @@ -1505,6 +1553,20 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +[[package]] +name = "rusqlite" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustc-hash" version = "2.1.2" @@ -1685,6 +1747,15 @@ dependencies = [ "serde", ] +[[package]] +name = "shift-store" +version = "0.1.0" +dependencies = [ + "rusqlite", + "tempfile", + "thiserror 2.0.18", +] + [[package]] name = "shift-wire" version = "0.0.0" @@ -1942,6 +2013,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "vergen" version = "9.0.6" @@ -1994,35 +2071,22 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.100" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" dependencies = [ "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", -] - -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" -dependencies = [ - "bumpalo", - "log", - "proc-macro2", - "quote", - "syn", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.100" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2030,22 +2094,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.100" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" dependencies = [ + "bumpalo", "proc-macro2", "quote", "syn", - "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.100" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" dependencies = [ "unicode-ident", ] diff --git a/Cargo.toml b/Cargo.toml index eee6f173..d7d13ec4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,3 +9,4 @@ shift-backends = { path = "crates/shift-backends" } shift-bridge = { path = "crates/shift-bridge" } shift-edit = { path = "crates/shift-edit" } shift-ir = { path = "crates/shift-ir" } +shift-store = { path = "crates/shift-store" } diff --git a/crates/shift-store/Cargo.toml b/crates/shift-store/Cargo.toml new file mode 100644 index 00000000..3182e61a --- /dev/null +++ b/crates/shift-store/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "shift-store" +version = "0.1.0" +edition = "2024" + +[dependencies] +rusqlite = { version = "0.37.0", features = ["blob", "backup", "limits"] } +thiserror = "2" + +[dev-dependencies] +tempfile = "3" diff --git a/crates/shift-store/README.md b/crates/shift-store/README.md new file mode 100644 index 00000000..504bcea9 --- /dev/null +++ b/crates/shift-store/README.md @@ -0,0 +1,40 @@ +# shift-store + +`shift-store` owns Shift's durable source database. + +The crate provides the storage boundary between the rest of the Rust application and SQLite. Callers should use typed store APIs exposed by this crate instead of preparing SQL statements or opening database connections directly. + +## Responsibilities + +- open and configure the SQLite connection; +- create and update the database schema; +- expose typed Rust APIs for source-store reads and writes; +- keep raw SQL local to this crate; +- preserve stable internal IDs for source entities; +- separate canonical source data from derived/index data as the store grows. + +## Design Goals + +- incremental writes; +- durability and crash-safe recovery; +- stable IDs; +- performant reads and writes for large fonts; +- clear handling of CJK-scale glyph inventories; +- efficient queries for references to and from source entities; +- support for components, kerning, and feature-related source data; +- clear separation between canonical source data and derived/index data; +- import/export support through typed store APIs rather than ad hoc SQL access. + +## Shape + +```text +src/ + connection.rs # opening and configuring SQLite + error.rs # store error type + glyph.rs # glyph persistence API + schema.rs # schema creation and versioning entry point + store.rs # ShiftStore connection owner + types.rs # storage-facing IDs and small value types +``` + +Tests live in `tests/store_test.rs` and should start as small persistence checks before broader integration tests are added. diff --git a/crates/shift-store/src/connection.rs b/crates/shift-store/src/connection.rs new file mode 100644 index 00000000..d859a5e6 --- /dev/null +++ b/crates/shift-store/src/connection.rs @@ -0,0 +1,25 @@ +use std::path::Path; + +use crate::{ShiftStore, StoreError, schema}; + +impl ShiftStore { + pub fn open(path: impl AsRef) -> Result { + let conn = rusqlite::Connection::open(path)?; + configure_connection(&conn)?; + schema::ensure_current(&conn)?; + Ok(Self { conn }) + } + + pub fn open_memory_for_test() -> Result { + let conn = rusqlite::Connection::open_in_memory()?; + configure_connection(&conn)?; + schema::ensure_current(&conn)?; + Ok(Self { conn }) + } +} + +fn configure_connection(conn: &rusqlite::Connection) -> Result<(), StoreError> { + conn.pragma_update(None, "foreign_keys", "ON")?; + conn.busy_timeout(std::time::Duration::from_millis(5_000))?; + Ok(()) +} diff --git a/crates/shift-store/src/error.rs b/crates/shift-store/src/error.rs new file mode 100644 index 00000000..90506c3c --- /dev/null +++ b/crates/shift-store/src/error.rs @@ -0,0 +1,5 @@ +#[derive(Debug, thiserror::Error)] +pub enum StoreError { + #[error("sqlite error")] + Sqlite(#[from] rusqlite::Error), +} diff --git a/crates/shift-store/src/glyph.rs b/crates/shift-store/src/glyph.rs new file mode 100644 index 00000000..8862564a --- /dev/null +++ b/crates/shift-store/src/glyph.rs @@ -0,0 +1,49 @@ +use crate::{GlyphId, ShiftStore, StoreError}; + +pub struct NewGlyph { + pub id: GlyphId, + pub name: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GlyphRecord { + pub id: GlyphId, + pub name: Option, +} + +impl ShiftStore { + pub fn create_glyph(&mut self, glyph: NewGlyph) -> Result<(), StoreError> { + self.conn.execute( + " + INSERT INTO glyphs (id, name) + VALUES (?1, ?2) + ", + rusqlite::params![glyph.id.as_str(), glyph.name], + )?; + + Ok(()) + } + + pub fn get_glyph(&self, id: &GlyphId) -> Result, StoreError> { + let mut stmt = self.conn.prepare( + " + SELECT id, name + FROM glyphs + WHERE id = ?1 + ", + )?; + + match stmt.query_row([id.as_str()], map_glyph_row) { + Ok(glyph) => Ok(Some(glyph)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(err) => Err(err.into()), + } + } +} + +fn map_glyph_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(GlyphRecord { + id: GlyphId::new(row.get::<_, String>(0)?), + name: row.get(1)?, + }) +} diff --git a/crates/shift-store/src/lib.rs b/crates/shift-store/src/lib.rs new file mode 100644 index 00000000..67e4db33 --- /dev/null +++ b/crates/shift-store/src/lib.rs @@ -0,0 +1,11 @@ +mod connection; +mod error; +mod glyph; +mod schema; +mod store; +mod types; + +pub use error::StoreError; +pub use glyph::{GlyphRecord, NewGlyph}; +pub use store::ShiftStore; +pub use types::{GlyphId, LayerId, RevisionId}; diff --git a/crates/shift-store/src/schema.rs b/crates/shift-store/src/schema.rs new file mode 100644 index 00000000..49a7db8e --- /dev/null +++ b/crates/shift-store/src/schema.rs @@ -0,0 +1,16 @@ +use crate::StoreError; + +pub(crate) const SCHEMA_V1: &str = r#" +CREATE TABLE IF NOT EXISTS glyphs ( + id TEXT PRIMARY KEY, + name TEXT +); + +CREATE INDEX IF NOT EXISTS glyphs_name_idx +ON glyphs(name); +"#; + +pub(crate) fn ensure_current(conn: &rusqlite::Connection) -> Result<(), StoreError> { + conn.execute_batch(SCHEMA_V1)?; + Ok(()) +} diff --git a/crates/shift-store/src/store.rs b/crates/shift-store/src/store.rs new file mode 100644 index 00000000..398cc350 --- /dev/null +++ b/crates/shift-store/src/store.rs @@ -0,0 +1,3 @@ +pub struct ShiftStore { + pub(crate) conn: rusqlite::Connection, +} diff --git a/crates/shift-store/src/types.rs b/crates/shift-store/src/types.rs new file mode 100644 index 00000000..80363fd1 --- /dev/null +++ b/crates/shift-store/src/types.rs @@ -0,0 +1,38 @@ +#[derive(Clone, Debug, Eq, PartialEq, Hash)] +pub struct GlyphId(String); + +impl GlyphId { + pub fn new(id: impl Into) -> Self { + Self(id.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Hash)] +pub struct LayerId(String); + +impl LayerId { + pub fn new(id: impl Into) -> Self { + Self(id.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct RevisionId(i64); + +impl RevisionId { + pub fn new(id: i64) -> Self { + Self(id) + } + + pub fn get(self) -> i64 { + self.0 + } +} diff --git a/crates/shift-store/tests/store_test.rs b/crates/shift-store/tests/store_test.rs new file mode 100644 index 00000000..c441b818 --- /dev/null +++ b/crates/shift-store/tests/store_test.rs @@ -0,0 +1,27 @@ +use shift_store::{GlyphId, NewGlyph, ShiftStore}; + +#[test] +fn opens_memory_store() { + ShiftStore::open_memory_for_test().expect("memory store should open"); +} + +#[test] +fn creates_and_reads_glyph() { + let mut store = ShiftStore::open_memory_for_test().expect("memory store should open"); + let glyph_id = GlyphId::new("glyph-A"); + + store + .create_glyph(NewGlyph { + id: glyph_id.clone(), + name: Some("A".to_string()), + }) + .expect("glyph should be created"); + + let glyph = store + .get_glyph(&glyph_id) + .expect("glyph query should succeed") + .expect("glyph should exist"); + + assert_eq!(glyph.id, glyph_id); + assert_eq!(glyph.name.as_deref(), Some("A")); +} From aee69284575d9da307a881ea3069430aab23a1bd Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Sat, 30 May 2026 13:49:46 +0100 Subject: [PATCH 2/3] feat(store): add axes, sources and source locations --- crates/shift-store/src/error.rs | 3 + crates/shift-store/src/lib.rs | 4 +- crates/shift-store/src/schema.rs | 30 +++ crates/shift-store/src/source.rs | 254 +++++++++++++++++++++++++ crates/shift-store/src/types.rs | 26 +++ crates/shift-store/tests/store_test.rs | 108 ++++++++++- 6 files changed, 423 insertions(+), 2 deletions(-) create mode 100644 crates/shift-store/src/source.rs diff --git a/crates/shift-store/src/error.rs b/crates/shift-store/src/error.rs index 90506c3c..2de79f1d 100644 --- a/crates/shift-store/src/error.rs +++ b/crates/shift-store/src/error.rs @@ -2,4 +2,7 @@ pub enum StoreError { #[error("sqlite error")] Sqlite(#[from] rusqlite::Error), + + #[error("unknown source kind: {0}")] + UnknownSourceKind(String), } diff --git a/crates/shift-store/src/lib.rs b/crates/shift-store/src/lib.rs index 67e4db33..d2b19670 100644 --- a/crates/shift-store/src/lib.rs +++ b/crates/shift-store/src/lib.rs @@ -2,10 +2,12 @@ mod connection; mod error; mod glyph; mod schema; +mod source; mod store; mod types; pub use error::StoreError; pub use glyph::{GlyphRecord, NewGlyph}; +pub use source::{AxisRecord, NewAxis, NewSource, SourceAxisLocation, SourceKind, SourceRecord}; pub use store::ShiftStore; -pub use types::{GlyphId, LayerId, RevisionId}; +pub use types::{AxisId, GlyphId, LayerId, RevisionId, SourceId}; diff --git a/crates/shift-store/src/schema.rs b/crates/shift-store/src/schema.rs index 49a7db8e..ddcf5fb1 100644 --- a/crates/shift-store/src/schema.rs +++ b/crates/shift-store/src/schema.rs @@ -1,6 +1,27 @@ use crate::StoreError; pub(crate) const SCHEMA_V1: &str = r#" +CREATE TABLE IF NOT EXISTS axes ( + id TEXT PRIMARY KEY, + tag TEXT NOT NULL, + name TEXT NOT NULL, + min_value REAL NOT NULL, + default_value REAL NOT NULL, + max_value REAL NOT NULL, + hidden INTEGER NOT NULL DEFAULT 0 CHECK (hidden IN (0, 1)) +); + +CREATE UNIQUE INDEX IF NOT EXISTS axes_tag_unique +ON axes(tag); + +CREATE TABLE IF NOT EXISTS sources ( + id TEXT PRIMARY KEY, + name TEXT, + family_name TEXT, + style_name TEXT, + kind TEXT NOT NULL +); + CREATE TABLE IF NOT EXISTS glyphs ( id TEXT PRIMARY KEY, name TEXT @@ -8,6 +29,15 @@ CREATE TABLE IF NOT EXISTS glyphs ( CREATE INDEX IF NOT EXISTS glyphs_name_idx ON glyphs(name); + +CREATE TABLE IF NOT EXISTS source_locations ( + source_id TEXT NOT NULL, + axis_id TEXT NOT NULL, + value REAL NOT NULL, + PRIMARY KEY (source_id, axis_id), + FOREIGN KEY (source_id) REFERENCES sources(id) ON DELETE CASCADE, + FOREIGN KEY (axis_id) REFERENCES axes(id) ON DELETE CASCADE +); "#; pub(crate) fn ensure_current(conn: &rusqlite::Connection) -> Result<(), StoreError> { diff --git a/crates/shift-store/src/source.rs b/crates/shift-store/src/source.rs new file mode 100644 index 00000000..dbe35953 --- /dev/null +++ b/crates/shift-store/src/source.rs @@ -0,0 +1,254 @@ +use crate::{AxisId, ShiftStore, SourceId, StoreError}; + +pub struct NewAxis { + pub id: AxisId, + pub tag: String, + pub name: String, + pub min_value: f64, + pub default_value: f64, + pub max_value: f64, + pub hidden: bool, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct AxisRecord { + pub id: AxisId, + pub tag: String, + pub name: String, + pub min_value: f64, + pub default_value: f64, + pub max_value: f64, + pub hidden: bool, +} + +pub struct NewSource { + pub id: SourceId, + pub name: Option, + pub family_name: Option, + pub style_name: Option, + pub kind: SourceKind, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct SourceRecord { + pub id: SourceId, + pub name: Option, + pub family_name: Option, + pub style_name: Option, + pub kind: SourceKind, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SourceKind { + Master, +} + +impl SourceKind { + fn as_str(&self) -> &'static str { + match self { + SourceKind::Master => "master", + } + } + + fn parse(value: String) -> Result { + match value.as_str() { + "master" => Ok(SourceKind::Master), + _ => Err(StoreError::UnknownSourceKind(value)), + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct SourceAxisLocation { + pub source_id: SourceId, + pub axis_id: AxisId, + pub value: f64, +} + +impl ShiftStore { + pub fn create_axis(&mut self, axis: NewAxis) -> Result<(), StoreError> { + self.conn.execute( + " + INSERT INTO axes ( + id, + tag, + name, + min_value, + default_value, + max_value, + hidden + ) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) + ", + rusqlite::params![ + axis.id.as_str(), + axis.tag, + axis.name, + axis.min_value, + axis.default_value, + axis.max_value, + axis.hidden, + ], + )?; + + Ok(()) + } + + pub fn get_axis(&self, id: &AxisId) -> Result, StoreError> { + let mut stmt = self.conn.prepare( + " + SELECT + id, + tag, + name, + min_value, + default_value, + max_value, + hidden + FROM axes + WHERE id = ?1 + ", + )?; + + match stmt.query_row([id.as_str()], map_axis_row) { + Ok(axis) => Ok(Some(axis)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(err) => Err(err.into()), + } + } + + pub fn create_source(&mut self, source: NewSource) -> Result<(), StoreError> { + self.conn.execute( + " + INSERT INTO sources ( + id, + name, + family_name, + style_name, + kind + ) + VALUES (?1, ?2, ?3, ?4, ?5) + ", + rusqlite::params![ + source.id.as_str(), + source.name, + source.family_name, + source.style_name, + source.kind.as_str(), + ], + )?; + + Ok(()) + } + + pub fn get_source(&self, id: &SourceId) -> Result, StoreError> { + let mut stmt = self.conn.prepare( + " + SELECT + id, + name, + family_name, + style_name, + kind + FROM sources + WHERE id = ?1 + ", + )?; + + match stmt.query_row([id.as_str()], map_source_row) { + Ok(source) => Ok(Some(source)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(err) => Err(err.into()), + } + } + + pub fn list_sources(&self) -> Result, StoreError> { + let mut stmt = self.conn.prepare( + " + SELECT + id, + name, + family_name, + style_name, + kind + FROM sources + ORDER BY id + ", + )?; + + let rows = stmt.query_map([], map_source_row)?; + rows.collect::, _>>() + .map_err(StoreError::from) + } + + pub fn set_source_location( + &mut self, + source_id: &SourceId, + axis_id: &AxisId, + value: f64, + ) -> Result<(), StoreError> { + self.conn.execute( + " + INSERT INTO source_locations (source_id, axis_id, value) + VALUES (?1, ?2, ?3) + ON CONFLICT(source_id, axis_id) DO UPDATE SET + value = excluded.value + ", + rusqlite::params![source_id.as_str(), axis_id.as_str(), value], + )?; + + Ok(()) + } + + pub fn get_source_locations( + &self, + source_id: &SourceId, + ) -> Result, StoreError> { + let mut stmt = self.conn.prepare( + " + SELECT source_id, axis_id, value + FROM source_locations + WHERE source_id = ?1 + ORDER BY axis_id + ", + )?; + + let rows = stmt.query_map([source_id.as_str()], map_source_location_row)?; + rows.collect::, _>>() + .map_err(StoreError::from) + } +} + +fn map_axis_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(AxisRecord { + id: AxisId::new(row.get::<_, String>(0)?), + tag: row.get(1)?, + name: row.get(2)?, + min_value: row.get(3)?, + default_value: row.get(4)?, + max_value: row.get(5)?, + hidden: row.get(6)?, + }) +} + +fn map_source_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let kind = SourceKind::parse(row.get(4)?).map_err(|err| { + rusqlite::Error::FromSqlConversionFailure(4, rusqlite::types::Type::Text, Box::new(err)) + })?; + + Ok(SourceRecord { + id: SourceId::new(row.get::<_, String>(0)?), + name: row.get(1)?, + family_name: row.get(2)?, + style_name: row.get(3)?, + kind, + }) +} + +fn map_source_location_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(SourceAxisLocation { + source_id: SourceId::new(row.get::<_, String>(0)?), + axis_id: AxisId::new(row.get::<_, String>(1)?), + value: row.get(2)?, + }) +} diff --git a/crates/shift-store/src/types.rs b/crates/shift-store/src/types.rs index 80363fd1..30b87697 100644 --- a/crates/shift-store/src/types.rs +++ b/crates/shift-store/src/types.rs @@ -1,3 +1,29 @@ +#[derive(Clone, Debug, Eq, PartialEq, Hash)] +pub struct AxisId(String); + +impl AxisId { + pub fn new(id: impl Into) -> Self { + Self(id.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Hash)] +pub struct SourceId(String); + +impl SourceId { + pub fn new(id: impl Into) -> Self { + Self(id.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + #[derive(Clone, Debug, Eq, PartialEq, Hash)] pub struct GlyphId(String); diff --git a/crates/shift-store/tests/store_test.rs b/crates/shift-store/tests/store_test.rs index c441b818..3e5878b7 100644 --- a/crates/shift-store/tests/store_test.rs +++ b/crates/shift-store/tests/store_test.rs @@ -1,4 +1,6 @@ -use shift_store::{GlyphId, NewGlyph, ShiftStore}; +use shift_store::{ + AxisId, GlyphId, NewAxis, NewGlyph, NewSource, ShiftStore, SourceId, SourceKind, +}; #[test] fn opens_memory_store() { @@ -25,3 +27,107 @@ fn creates_and_reads_glyph() { assert_eq!(glyph.id, glyph_id); assert_eq!(glyph.name.as_deref(), Some("A")); } + +#[test] +fn creates_and_reads_axis() { + let mut store = ShiftStore::open_memory_for_test().expect("memory store should open"); + + let axis_id = create_weight_axis(&mut store); + + let axis = store + .get_axis(&axis_id) + .expect("axis query should succeed") + .expect("axis should exist"); + + assert_eq!(axis.id, axis_id); + assert_eq!(axis.tag, "wght"); + assert_eq!(axis.name, "Weight"); + assert_eq!(axis.min_value, 100.0); + assert_eq!(axis.default_value, 400.0); + assert_eq!(axis.max_value, 800.0); + assert!(!axis.hidden); +} + +#[test] +fn creates_and_reads_source() { + let mut store = ShiftStore::open_memory_for_test().expect("memory store should open"); + let source_id = create_regular_source(&mut store); + + let source = store + .get_source(&source_id) + .expect("source query should succeed") + .expect("source should exist"); + + assert_eq!(source.id, source_id); + assert_eq!(source.name.as_deref(), Some("Regular")); + assert_eq!(source.family_name.as_deref(), Some("Shift Sans")); + assert_eq!(source.style_name.as_deref(), Some("Regular")); + assert_eq!(source.kind, SourceKind::Master); +} + +#[test] +fn sets_and_reads_source_location() { + let mut store = ShiftStore::open_memory_for_test().expect("memory store should open"); + let axis_id = create_weight_axis(&mut store); + let source_id = create_regular_source(&mut store); + + store + .set_source_location(&source_id, &axis_id, 400.0) + .expect("source location should be set"); + + let locations = store + .get_source_locations(&source_id) + .expect("source locations query should succeed"); + + assert_eq!(locations.len(), 1); + assert_eq!(locations[0].source_id, source_id); + assert_eq!(locations[0].axis_id, axis_id); + assert_eq!(locations[0].value, 400.0); +} + +#[test] +fn source_location_requires_existing_source_and_axis() { + let mut store = ShiftStore::open_memory_for_test().expect("memory store should open"); + + let result = store.set_source_location( + &SourceId::new("source-missing"), + &AxisId::new("axis-wght"), + 400.0, + ); + + assert!(result.is_err()); +} + +fn create_weight_axis(store: &mut ShiftStore) -> AxisId { + let axis_id = AxisId::new("axis-wght"); + + store + .create_axis(NewAxis { + id: axis_id.clone(), + tag: "wght".to_string(), + name: "Weight".to_string(), + min_value: 100.0, + default_value: 400.0, + max_value: 800.0, + hidden: false, + }) + .expect("axis should be created"); + + axis_id +} + +fn create_regular_source(store: &mut ShiftStore) -> SourceId { + let source_id = SourceId::new("source-regular"); + + store + .create_source(NewSource { + id: source_id.clone(), + name: Some("Regular".to_string()), + family_name: Some("Shift Sans".to_string()), + style_name: Some("Regular".to_string()), + kind: SourceKind::Master, + }) + .expect("source should be created"); + + source_id +} From 8bbe894a891440e97d2ef6151da5c64a5f39b369 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Sat, 30 May 2026 14:01:28 +0100 Subject: [PATCH 3/3] feat(store): add glyph layers and components --- crates/shift-store/src/component.rs | 97 ++++++++++++++ crates/shift-store/src/layer.rs | 91 +++++++++++++ crates/shift-store/src/lib.rs | 6 +- crates/shift-store/src/schema.rs | 36 ++++++ crates/shift-store/src/types.rs | 13 ++ crates/shift-store/tests/store_test.rs | 170 ++++++++++++++++++++++++- 6 files changed, 411 insertions(+), 2 deletions(-) create mode 100644 crates/shift-store/src/component.rs create mode 100644 crates/shift-store/src/layer.rs diff --git a/crates/shift-store/src/component.rs b/crates/shift-store/src/component.rs new file mode 100644 index 00000000..9c042711 --- /dev/null +++ b/crates/shift-store/src/component.rs @@ -0,0 +1,97 @@ +use crate::{ComponentId, GlyphId, LayerId, ShiftStore, StoreError}; + +pub struct NewGlyphComponent { + pub id: ComponentId, + pub layer_id: LayerId, + pub base_glyph_id: GlyphId, + pub order_index: i64, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GlyphComponentRecord { + pub id: ComponentId, + pub layer_id: LayerId, + pub base_glyph_id: GlyphId, + pub order_index: i64, +} + +impl ShiftStore { + pub fn create_glyph_component( + &mut self, + component: NewGlyphComponent, + ) -> Result<(), StoreError> { + self.conn.execute( + " + INSERT INTO glyph_components ( + id, + layer_id, + base_glyph_id, + order_index + ) + VALUES (?1, ?2, ?3, ?4) + ", + rusqlite::params![ + component.id.as_str(), + component.layer_id.as_str(), + component.base_glyph_id.as_str(), + component.order_index, + ], + )?; + + Ok(()) + } + + pub fn get_glyph_component( + &self, + id: &ComponentId, + ) -> Result, StoreError> { + let mut stmt = self.conn.prepare( + " + SELECT + id, + layer_id, + base_glyph_id, + order_index + FROM glyph_components + WHERE id = ?1 + ", + )?; + + match stmt.query_row([id.as_str()], map_glyph_component_row) { + Ok(component) => Ok(Some(component)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(err) => Err(err.into()), + } + } + + pub fn list_glyph_components_for_layer( + &self, + layer_id: &LayerId, + ) -> Result, StoreError> { + let mut stmt = self.conn.prepare( + " + SELECT + id, + layer_id, + base_glyph_id, + order_index + FROM glyph_components + WHERE layer_id = ?1 + ORDER BY order_index, id + ", + )?; + + let rows = stmt.query_map([layer_id.as_str()], map_glyph_component_row)?; + rows.collect::, _>>() + .map_err(StoreError::from) + } +} + +fn map_glyph_component_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(GlyphComponentRecord { + id: ComponentId::new(row.get::<_, String>(0)?), + layer_id: LayerId::new(row.get::<_, String>(1)?), + base_glyph_id: GlyphId::new(row.get::<_, String>(2)?), + order_index: row.get(3)?, + }) +} diff --git a/crates/shift-store/src/layer.rs b/crates/shift-store/src/layer.rs new file mode 100644 index 00000000..626fcd9f --- /dev/null +++ b/crates/shift-store/src/layer.rs @@ -0,0 +1,91 @@ +use crate::{GlyphId, LayerId, ShiftStore, SourceId, StoreError}; + +pub struct NewGlyphLayer { + pub id: LayerId, + pub glyph_id: GlyphId, + pub source_id: SourceId, + pub name: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GlyphLayerRecord { + pub id: LayerId, + pub glyph_id: GlyphId, + pub source_id: SourceId, + pub name: Option, +} + +impl ShiftStore { + pub fn create_glyph_layer(&mut self, layer: NewGlyphLayer) -> Result<(), StoreError> { + self.conn.execute( + " + INSERT INTO glyph_layers ( + id, + glyph_id, + source_id, + name + ) + VALUES (?1, ?2, ?3, ?4) + ", + rusqlite::params![ + layer.id.as_str(), + layer.glyph_id.as_str(), + layer.source_id.as_str(), + layer.name, + ], + )?; + + Ok(()) + } + + pub fn get_glyph_layer(&self, id: &LayerId) -> Result, StoreError> { + let mut stmt = self.conn.prepare( + " + SELECT + id, + glyph_id, + source_id, + name + FROM glyph_layers + WHERE id = ?1 + ", + )?; + + match stmt.query_row([id.as_str()], map_glyph_layer_row) { + Ok(layer) => Ok(Some(layer)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(err) => Err(err.into()), + } + } + + pub fn list_glyph_layers_for_glyph( + &self, + glyph_id: &GlyphId, + ) -> Result, StoreError> { + let mut stmt = self.conn.prepare( + " + SELECT + id, + glyph_id, + source_id, + name + FROM glyph_layers + WHERE glyph_id = ?1 + ORDER BY source_id, id + ", + )?; + + let rows = stmt.query_map([glyph_id.as_str()], map_glyph_layer_row)?; + rows.collect::, _>>() + .map_err(StoreError::from) + } +} + +fn map_glyph_layer_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(GlyphLayerRecord { + id: LayerId::new(row.get::<_, String>(0)?), + glyph_id: GlyphId::new(row.get::<_, String>(1)?), + source_id: SourceId::new(row.get::<_, String>(2)?), + name: row.get(3)?, + }) +} diff --git a/crates/shift-store/src/lib.rs b/crates/shift-store/src/lib.rs index d2b19670..4fbeaae2 100644 --- a/crates/shift-store/src/lib.rs +++ b/crates/shift-store/src/lib.rs @@ -1,13 +1,17 @@ +mod component; mod connection; mod error; mod glyph; +mod layer; mod schema; mod source; mod store; mod types; +pub use component::{GlyphComponentRecord, NewGlyphComponent}; pub use error::StoreError; pub use glyph::{GlyphRecord, NewGlyph}; +pub use layer::{GlyphLayerRecord, NewGlyphLayer}; pub use source::{AxisRecord, NewAxis, NewSource, SourceAxisLocation, SourceKind, SourceRecord}; pub use store::ShiftStore; -pub use types::{AxisId, GlyphId, LayerId, RevisionId, SourceId}; +pub use types::{AxisId, ComponentId, GlyphId, LayerId, RevisionId, SourceId}; diff --git a/crates/shift-store/src/schema.rs b/crates/shift-store/src/schema.rs index ddcf5fb1..84ec6100 100644 --- a/crates/shift-store/src/schema.rs +++ b/crates/shift-store/src/schema.rs @@ -30,6 +30,42 @@ CREATE TABLE IF NOT EXISTS glyphs ( CREATE INDEX IF NOT EXISTS glyphs_name_idx ON glyphs(name); +CREATE TABLE IF NOT EXISTS glyph_layers ( + id TEXT PRIMARY KEY, + glyph_id TEXT NOT NULL, + source_id TEXT NOT NULL, + name TEXT, + FOREIGN KEY (glyph_id) REFERENCES glyphs(id) ON DELETE CASCADE, + FOREIGN KEY (source_id) REFERENCES sources(id) ON DELETE CASCADE +); + +CREATE UNIQUE INDEX IF NOT EXISTS glyph_layers_glyph_source_unique +ON glyph_layers(glyph_id, source_id); + +CREATE INDEX IF NOT EXISTS glyph_layers_glyph_id_idx +ON glyph_layers(glyph_id); + +CREATE INDEX IF NOT EXISTS glyph_layers_source_id_idx +ON glyph_layers(source_id); + +CREATE TABLE IF NOT EXISTS glyph_components ( + id TEXT PRIMARY KEY, + layer_id TEXT NOT NULL, + base_glyph_id TEXT NOT NULL, + order_index INTEGER NOT NULL, + FOREIGN KEY (layer_id) REFERENCES glyph_layers(id) ON DELETE CASCADE, + FOREIGN KEY (base_glyph_id) REFERENCES glyphs(id) ON DELETE CASCADE +); + +CREATE UNIQUE INDEX IF NOT EXISTS glyph_components_layer_order_unique +ON glyph_components(layer_id, order_index); + +CREATE INDEX IF NOT EXISTS glyph_components_layer_id_idx +ON glyph_components(layer_id); + +CREATE INDEX IF NOT EXISTS glyph_components_base_glyph_id_idx +ON glyph_components(base_glyph_id); + CREATE TABLE IF NOT EXISTS source_locations ( source_id TEXT NOT NULL, axis_id TEXT NOT NULL, diff --git a/crates/shift-store/src/types.rs b/crates/shift-store/src/types.rs index 30b87697..b9791716 100644 --- a/crates/shift-store/src/types.rs +++ b/crates/shift-store/src/types.rs @@ -1,3 +1,16 @@ +#[derive(Clone, Debug, Eq, PartialEq, Hash)] +pub struct ComponentId(String); + +impl ComponentId { + pub fn new(id: impl Into) -> Self { + Self(id.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + #[derive(Clone, Debug, Eq, PartialEq, Hash)] pub struct AxisId(String); diff --git a/crates/shift-store/tests/store_test.rs b/crates/shift-store/tests/store_test.rs index 3e5878b7..0b08de1c 100644 --- a/crates/shift-store/tests/store_test.rs +++ b/crates/shift-store/tests/store_test.rs @@ -1,5 +1,6 @@ use shift_store::{ - AxisId, GlyphId, NewAxis, NewGlyph, NewSource, ShiftStore, SourceId, SourceKind, + AxisId, ComponentId, GlyphId, LayerId, NewAxis, NewGlyph, NewGlyphComponent, NewGlyphLayer, + NewSource, ShiftStore, SourceId, SourceKind, }; #[test] @@ -98,6 +99,173 @@ fn source_location_requires_existing_source_and_axis() { assert!(result.is_err()); } +#[test] +fn creates_and_reads_glyph_layer() { + let mut store = ShiftStore::open_memory_for_test().expect("memory store should open"); + let glyph_id = create_glyph_a(&mut store); + let source_id = create_regular_source(&mut store); + let layer_id = create_default_glyph_layer(&mut store, &glyph_id, &source_id); + + let layer = store + .get_glyph_layer(&layer_id) + .expect("glyph layer query should succeed") + .expect("glyph layer should exist"); + + assert_eq!(layer.id, layer_id); + assert_eq!(layer.glyph_id, glyph_id); + assert_eq!(layer.source_id, source_id); + assert_eq!(layer.name.as_deref(), Some("Regular")); +} + +#[test] +fn glyph_layer_requires_existing_glyph_and_source() { + let mut store = ShiftStore::open_memory_for_test().expect("memory store should open"); + + let result = store.create_glyph_layer(NewGlyphLayer { + id: LayerId::new("layer-A-regular"), + glyph_id: GlyphId::new("glyph-missing"), + source_id: SourceId::new("source-missing"), + name: Some("Regular".to_string()), + }); + + assert!(result.is_err()); +} + +#[test] +fn lists_glyph_layers_for_glyph() { + let mut store = ShiftStore::open_memory_for_test().expect("memory store should open"); + let glyph_id = create_glyph_a(&mut store); + let source_id = create_regular_source(&mut store); + let layer_id = create_default_glyph_layer(&mut store, &glyph_id, &source_id); + + let layers = store + .list_glyph_layers_for_glyph(&glyph_id) + .expect("glyph layers query should succeed"); + + assert_eq!(layers.len(), 1); + assert_eq!(layers[0].id, layer_id); + assert_eq!(layers[0].glyph_id, glyph_id); + assert_eq!(layers[0].source_id, source_id); +} + +#[test] +fn creates_and_reads_glyph_component() { + let mut store = ShiftStore::open_memory_for_test().expect("memory store should open"); + let glyph_id = create_glyph_a(&mut store); + let base_glyph_id = create_glyph_b(&mut store); + let source_id = create_regular_source(&mut store); + let layer_id = create_default_glyph_layer(&mut store, &glyph_id, &source_id); + let component_id = create_default_component(&mut store, &layer_id, &base_glyph_id); + + let component = store + .get_glyph_component(&component_id) + .expect("glyph component query should succeed") + .expect("glyph component should exist"); + + assert_eq!(component.id, component_id); + assert_eq!(component.layer_id, layer_id); + assert_eq!(component.base_glyph_id, base_glyph_id); + assert_eq!(component.order_index, 0); +} + +#[test] +fn glyph_component_requires_existing_layer_and_base_glyph() { + let mut store = ShiftStore::open_memory_for_test().expect("memory store should open"); + + let result = store.create_glyph_component(NewGlyphComponent { + id: ComponentId::new("component-A-B"), + layer_id: LayerId::new("layer-missing"), + base_glyph_id: GlyphId::new("glyph-missing"), + order_index: 0, + }); + + assert!(result.is_err()); +} + +#[test] +fn lists_glyph_components_for_layer() { + let mut store = ShiftStore::open_memory_for_test().expect("memory store should open"); + let glyph_id = create_glyph_a(&mut store); + let base_glyph_id = create_glyph_b(&mut store); + let source_id = create_regular_source(&mut store); + let layer_id = create_default_glyph_layer(&mut store, &glyph_id, &source_id); + let component_id = create_default_component(&mut store, &layer_id, &base_glyph_id); + + let components = store + .list_glyph_components_for_layer(&layer_id) + .expect("glyph components query should succeed"); + + assert_eq!(components.len(), 1); + assert_eq!(components[0].id, component_id); + assert_eq!(components[0].layer_id, layer_id); + assert_eq!(components[0].base_glyph_id, base_glyph_id); + assert_eq!(components[0].order_index, 0); +} + +fn create_glyph_a(store: &mut ShiftStore) -> GlyphId { + let glyph_id = GlyphId::new("glyph-A"); + + store + .create_glyph(NewGlyph { + id: glyph_id.clone(), + name: Some("A".to_string()), + }) + .expect("glyph should be created"); + + glyph_id +} + +fn create_glyph_b(store: &mut ShiftStore) -> GlyphId { + let glyph_id = GlyphId::new("glyph-B"); + + store + .create_glyph(NewGlyph { + id: glyph_id.clone(), + name: Some("B".to_string()), + }) + .expect("glyph should be created"); + + glyph_id +} + +fn create_default_glyph_layer( + store: &mut ShiftStore, + glyph_id: &GlyphId, + source_id: &SourceId, +) -> LayerId { + let layer_id = LayerId::new("layer-A-regular"); + + store + .create_glyph_layer(NewGlyphLayer { + id: layer_id.clone(), + glyph_id: glyph_id.clone(), + source_id: source_id.clone(), + name: Some("Regular".to_string()), + }) + .expect("glyph layer should be created"); + + layer_id +} + +fn create_default_component( + store: &mut ShiftStore, + layer_id: &LayerId, + base_glyph_id: &GlyphId, +) -> ComponentId { + let component_id = ComponentId::new("component-A-B"); + + store + .create_glyph_component(NewGlyphComponent { + id: component_id.clone(), + layer_id: layer_id.clone(), + base_glyph_id: base_glyph_id.clone(), + order_index: 0, + }) + .expect("glyph component should be created"); + + component_id +} + fn create_weight_axis(store: &mut ShiftStore) -> AxisId { let axis_id = AxisId::new("axis-wght");