Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
a9b8ed2
feat(executor): short-circuit literal language in resolve_property_va…
HexaField Jun 3, 2026
b93ae1f
refactor(literal): stop wrapping primitives in synthetic envelope (V6)
HexaField Jun 3, 2026
d15a6cb
refactor(sdk): simplify parseLit; Channel V values are plain (V11)
HexaField Jun 3, 2026
d494d7c
test: assert plain-value round-trip for resolveLanguage=literal (T1-T3)
HexaField Jun 3, 2026
760db9a
feat(executor): restore signed-envelope to plain-literal migration (b…
HexaField Jun 3, 2026
ce5f738
perf(executor): index-friendly WHERE for literal properties (V4)
HexaField Jun 3, 2026
b68ebe0
perf(executor): index-friendly WHERE in projection (V5)
HexaField Jun 3, 2026
8b317cd
test: split legacy-envelope WHERE tests from plain-literal WHERE test…
HexaField Jun 3, 2026
f886c8c
ci: re-trigger (initial workflow failed to start)
HexaField Jun 3, 2026
c28d64d
docs: tighten comments — drop process labels, explain non-obvious intent
HexaField Jun 3, 2026
f4273de
test: indexed-IRI vs fn/parse_literal microbench
HexaField Jun 4, 2026
83a8d0c
Merge remote-tracking branch 'origin/dev' into refactor/literal-chann…
HexaField Jun 4, 2026
08240a0
ci: re-trigger after dev merge
HexaField Jun 4, 2026
f4fbdb3
ci: re-trigger after dev merge
HexaField Jun 8, 2026
df05f46
style: cargo fmt
HexaField Jun 8, 2026
46dbb7b
Merge branch 'dev' into refactor/literal-channel-v-separation
lucksus Jun 10, 2026
7ff5fbf
review: address CodeRabbit comments on #837
HexaField Jun 11, 2026
561aa38
fix(core): bypass createExpression for resolveLanguage: "literal" on …
HexaField Jun 11, 2026
f30b4d6
fix(literal-bypass): leave URI-shaped strings raw — align write with …
HexaField Jun 11, 2026
121e308
test(prolog-and-literals): bump subscription-perf poll timeout 5s → 30s
HexaField Jun 11, 2026
5303e87
Merge branch 'dev' into refactor/literal-channel-v-separation
lucksus Jun 11, 2026
751acd7
test(prolog-and-literals): bump subscription-perf poll timeout 30s → 60s
HexaField Jun 11, 2026
1fb8cf8
Merge branch 'dev' into refactor/literal-channel-v-separation
lucksus Jun 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions core/src/Literal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,13 @@ export class Literal {
return parseFloat(numberString)
}

if(body.startsWith("boolean:")) {
const boolString = decodeURIComponent(body.substring(8))
if (boolString === "true") return true
if (boolString === "false") return false
throw new Error(`Can't parse boolean literal: ${boolString}`)
}

if(body.startsWith("json:")) {
const json = body.substring(5)
return JSON.parse(decodeURIComponent(json))
Expand Down
20 changes: 18 additions & 2 deletions core/src/model/Ad4mModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { isArrayType, determinePredicate, determineNamespace, buildModelFromJSON
import type { SHACLShape } from "../shacl/SHACLShape";
import type { JSONSchemaProperty, JSONSchema, JSONSchemaToModelOptions } from "./json-schema";

import { buildSPARQLQuery } from "./query-sparql";
import { buildSPARQLQuery, valueToLiteralIri } from "./query-sparql";
import { ModelQueryBuilder } from "./ModelQueryBuilder";
import {
normalizeValue,
Expand Down Expand Up @@ -1091,7 +1091,23 @@ export class Ad4mModel {
}

if (resolveLanguage) {
value = await this._perspective.createExpression(value, resolveLanguage);
// For the built-in "literal" resolver we produce the deterministic
// `literal:string:` / `:number:` / `:boolean:` / `:json:` URL directly
// instead of round-tripping through `createExpression`. The Rust
// mirror of this bypass lives in `resolve_property_value` — without
// both sides, the same value would land as `literal:json:<signed
// envelope>` on writes through this path, defeating the indexed
// equality lookups the WHERE builders now use.
//
// Route through `valueToLiteralIri` (also used by `queryToSPARQL`)
// so URI-shaped strings stay raw on both write AND read: writing
// `"https://example.com"` keeps it as `<https://example.com>`,
// matching what WHERE filters generate for the same value.
if (resolveLanguage === "literal") {
value = valueToLiteralIri(value);
} else {
value = await this._perspective.createExpression(value, resolveLanguage);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

await this._perspective.executeAction(actions, this._baseExpression, [{ name: "value", value }], batchId);
Expand Down
8 changes: 7 additions & 1 deletion core/src/model/query-sparql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,14 @@ function looksLikeUri(value: string): boolean {
* Convert a JS value to its literal: IRI form, matching how the Rust executor
* stores property values that don't have a resolveLanguage set.
* Strings that already look like URIs are returned as-is.
*
* Exported so write-side helpers (e.g. `Ad4mModel.setProperty`'s literal
* resolveLanguage bypass) can keep their on-disk form aligned with what
* `queryToSPARQL()` filters against — otherwise the same value can be
* stored as `<literal:string:https%3A...>` from one path while WHERE
* builders probe `<https://example.com>` from the other.
*/
function valueToLiteralIri(value: any): string {
export function valueToLiteralIri(value: any): string {
if (typeof value === 'string') {
if (looksLikeUri(value)) return value;
return Literal.from(value).toUrl();
Expand Down
11 changes: 5 additions & 6 deletions core/src/perspectives/SparqlBindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,17 +47,16 @@ export function parseSparqlCount(result: CountBinding[] | undefined | null): num
/**
* Decode a `Literal`-encoded SPARQL binding back to a plain string.
*
* Mirrors Flux's local `parseLit` helper. Returns `''` for `undefined`/empty
* input. Falls through to the raw value if decoding fails (defensive — the
* binding may already be a plain string for unwrapped properties).
* Returns `''` for `undefined`/empty input. Falls through to the raw value
* if decoding fails — bindings against properties without `resolveLanguage`
* are already raw URIs and should pass through unchanged. `literal:json:`
* objects are JSON-stringified for display.
*/
export function parseLit(val: string | undefined | null): string {
if (val === undefined || val === null || val === '') return '';
try {
const result = Literal.fromUrl(val).get();
if (result && typeof result === 'object') {
return (result as { data?: string }).data ?? JSON.stringify(result);
}
if (typeof result === 'object' && result !== null) return JSON.stringify(result);
return String(result);
} catch {
return val;
Expand Down
37 changes: 9 additions & 28 deletions rust-executor/src/languages/literal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,11 @@ pub fn literal_encode(value: &JsonValue) -> String {

/// Decode a literal URL expression part back into a JSON value.
///
/// Mirrors the TypeScript `Literal.fromUrl("literal://<expression_part>").get()` behavior.
/// If the decoded value is not an object (i.e., is a primitive), wraps it in a standard
/// expression envelope with `author`, `timestamp`, `data`, and `proof` fields.
/// Mirrors the TypeScript `Literal.fromUrl("literal:<expression_part>").get()`.
/// `string:` / `number:` / `boolean:` decode to their typed primitive. `json:`
/// payloads are returned as the decoded JSON value verbatim — if the payload
/// happens to be a signed-expression envelope (`{author, timestamp, data, proof}`),
/// it is the caller's job to interpret that shape.
pub fn literal_decode(expression_part: &str) -> Result<JsonValue, LanguageError> {
let value = if let Some(rest) = expression_part.strip_prefix("string:") {
let decoded = percent_decode_str(rest).decode_utf8().map_err(|e| {
Expand Down Expand Up @@ -86,27 +88,7 @@ pub fn literal_decode(expression_part: &str) -> Result<JsonValue, LanguageError>
serde_json::from_str(&decoded).unwrap_or(JsonValue::String(decoded.to_string()))
};

// If the value is already an object (e.g., a full expression), return it as-is.
// Otherwise, wrap it in a standard expression envelope.
if value.is_object() {
Ok(value)
} else {
let mut envelope = serde_json::Map::new();
envelope.insert(
"author".to_string(),
JsonValue::String("<unknown>".to_string()),
);
envelope.insert(
"timestamp".to_string(),
JsonValue::String("<unknown>".to_string()),
);
envelope.insert("data".to_string(), value);
envelope.insert(
"proof".to_string(),
JsonValue::Object(serde_json::Map::new()),
);
Ok(JsonValue::Object(envelope))
}
Ok(value)
}

#[cfg(test)]
Expand All @@ -119,8 +101,7 @@ mod tests {
let encoded = literal_encode(&value);
assert!(encoded.starts_with("string:"));
let decoded = literal_decode(&encoded).unwrap();
// Primitives get wrapped in an envelope
assert_eq!(decoded["data"], value);
assert_eq!(decoded, value);
}

#[test]
Expand All @@ -129,7 +110,7 @@ mod tests {
let encoded = literal_encode(&value);
assert!(encoded.starts_with("number:"));
let decoded = literal_decode(&encoded).unwrap();
assert_eq!(decoded["data"], value);
assert_eq!(decoded, value);
}

#[test]
Expand All @@ -138,7 +119,7 @@ mod tests {
let encoded = literal_encode(&value);
assert!(encoded.starts_with("boolean:"));
let decoded = literal_decode(&encoded).unwrap();
assert_eq!(decoded["data"], value);
assert_eq!(decoded, value);
}

#[test]
Expand Down
29 changes: 29 additions & 0 deletions rust-executor/src/perspectives/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,35 @@ pub fn initialize_from_db() {
Err(e) => log::warn!("Reifier migration for {}: {}", handle_clone.uuid, e),
}

// Signed-envelope → plain-literal migration. The new indexed WHERE
// path probes `literal:*` targets directly, so if this migration
// fails midway through the perspective is left with a mix of legacy
// signed-envelope targets and new plain-literal targets — equality
// filters would then silently miss the unmigrated rows. Skip
// initialising the perspective on error so the next executor restart
// can retry the migration cleanly rather than serving stale results.
match p.sparql_store.migrate_signed_envelopes_to_plain_literals() {
Ok(count) if count > 0 => {
log::info!(
"🔄 Signed-envelope migration for {}: {} envelopes converted",
handle_clone.uuid,
count
);
}
Ok(_) => {} // Already migrated or nothing to migrate
Err(e) => {
log::error!(
"Signed-envelope migration failed for {}: {} — \
skipping perspective init to avoid mixing legacy and \
migrated link targets under the new indexed WHERE path. \
Will retry on next executor restart.",
handle_clone.uuid,
e
);
return;
}
}

// Rebuild SPARQL index from existing links
// Skip SPARQL rebuild if persistent store already has data
if p.sparql_store.has_data() {
Expand Down
Loading