saved.kt
src/jvmMain/kotlin/afield/document/properties/saved.kt · 274 lines
package and imports — 43 lines, from line 1
@file:afield.runtime.completion.CompletionCategory("Data")
package afield.document.properties
import afield.data.collections.invoke
import afield.data.collections.s
import afield.data.collections.times
import afield.document.graph.minusAssign
import afield.document.graph.plusAssign
import afield.graphics.render.rangeTo
import afield.math.autodiff.compareTo
import afield.math.autodiff.div
import afield.math.autodiff.minus
import afield.math.autodiff.plus
import afield.math.autodiff.times
import afield.math.linalg.invoke
import afield.math.linalg.remAssign
import afield.math.linalg.set
import afield.math.linalg.times
import afield.math.units.div
import afield.math.units.minus
import afield.math.units.plus
import afield.math.units.times
import afield.runtime.lifecycle.cached
import afield.audio.synth.*
import afield.data.collections.get
import afield.document.workspace.absolutePath
import afield.document.workspace.file
import afield.document.workspace.path
import afield.document.graph.Box
import afield.document.persistence.FieldJson
import afield.app.server.ReadOnlyDocuments
import afield.document.persistence.safeSave
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerializationException
import kotlinx.serialization.serializer
import java.io.File
import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty
import kotlin.reflect.KType
import kotlin.reflect.typeOf
Per-box persistence for Kotlin values, on top of kotlinx.serialization. var n by saved { 0 }
declares a property that loads itself on first read and writes itself back on every assignment,
as JSON at <document directory>/<box id>/<property name>.json, encoded with Field's
FieldJson and written through the atomic safeSave. The no-argument saved overload skips
the default lambda but only for List, Set and Map (see defaultSavedValue); anything else
demands an explicit default.
The delegate itself is SavedJsonValue; it takes the box from its receiver or from the ambient
Box.context, so it needs an initialised box and session and errors out otherwise. toJSONFile
and fromJSONFile do the same encode/decode against an explicit File with no delegate. Any
failure to read, write or serialize is rethrown as a SerializationException naming the file.
inline fun <reified T> toJSONFile(file: File, value: T)
Writes any serializable value to a JSON file, atomically, creating parent directories as needed.
The no-delegate half of this file: use it when you want to choose the path yourself rather than let saved
derive one. Atomic via Field's safeSave, so an interrupted write cannot leave a half-written file behind.
@throws kotlinx.serialization.SerializationException wrapping any failure — a missing serializer, a permission error, a full disk — with the absolute path in the message
@OptIn(ExperimentalSerializationApi::class)
inline fun <reified T> toJSONFile(file: File, value: T) {
try {
file.parentFile?.mkdirs()
@Suppress("UNCHECKED_CAST")
val serializer = FieldJson.serializersModule.serializer(typeOf<T>()) as KSerializer<T>
// atomic = true because the KDoc promises it, and because the non-atomic path renames the
// existing file to a backup *before* writing: an interruption there leaves nothing at the path.
file.safeSave(FieldJson.encodeToString(serializer, value), atomic = true)
} catch (t: Throwable) {
throw SerializationException(
"Couldn't save to ${file.absolutePath}: ${t.message}",
t
)
}
}
inline fun <reified T> fromJSONFile(file: File, defaultValue: () -> T): T {
Reads a value back from a JSON file, or returns defaultValue if the file does not exist.
A missing file is not an error — that is the whole point, so first-run code needs no special case. A file that exists but cannot be decoded is an error, so a corrupted or schema-drifted file throws rather than silently reverting to the default.
@throws kotlinx.serialization.SerializationException on any decode or read failure, with the path in the message (which reads "Couldn't save to …" — the message is wrong about the direction, though the path is right)
@OptIn(ExperimentalSerializationApi::class)
inline fun <reified T> fromJSONFile(file: File, defaultValue: () -> T): T {
try {
if (file.exists()) {
@Suppress("UNCHECKED_CAST")
val serializer = FieldJson.serializersModule.serializer(typeOf<T>()) as KSerializer<T>
return FieldJson.decodeFromString(serializer, file.readText())
} else {
return defaultValue()
}
} catch (t: Throwable) {
throw SerializationException(
"Couldn't save to ${file.absolutePath}: ${t.message}",
t
)
}
}
class SavedJsonValue<T>
The delegate behind var n by saved { 0 }: a property that loads itself from disk on first read and writes
itself back on every assignment.
The file is derived, not chosen — <document directory>/<box id>/<property name>.json — so renaming the
property or the box orphans the old file and the value appears to reset. The box comes from the delegate's
receiver if that is a Box, otherwise from the ambient Box.context.
Every write hits the disk synchronously and atomically, so this is persistence for settings and small state, not for something you assign in a per-frame loop. The value is cached in memory after the first read, which also means an edit to the file underneath a running box is not noticed.
Construct it through saved rather than directly; that is what supplies the serializer for T.
class SavedJsonValue<T>(
private val serializer: KSerializer<T>,
private val defaultValue: () -> T
) : ReadWriteProperty<Any?, T> {
private var loaded = false
private var value: T? = null
override fun getValue(thisRef: Any?, property: KProperty<*>): T
The stored value, loading it from disk on the first read of this box's run.
If there is no file yet, the default is computed and immediately written, so the file exists from then on — except in a read-only session, where it is computed and returned but not written. A file that cannot be decoded throws rather than falling back to the default.
@throws kotlinx.serialization.SerializationException if the existing file cannot be decoded
@throws IllegalStateException when there is no active Box context at all
@throws IllegalArgumentException when there is a box but it has no initialised session to derive a path
from
override fun getValue(thisRef: Any?, property: KProperty<*>): T {
if (!loaded) {
val box = resolveBox(thisRef)
val file = fileFor(box, property)
value = if (file.exists()) {
try {
FieldJson.decodeFromString(serializer, file.readText())
} catch (t: Throwable) {
throw SerializationException(
"Couldn't decode saved value for '${property.name}' from ${file.absolutePath}: ${t.message}",
t
)
}
} else {
defaultValue().also { save(box, file, it, property) }
}
loaded = true
}
@Suppress("UNCHECKED_CAST")
return value as T
}
override fun setValue(thisRef: Any?, property: KProperty<*>, value: T)
Stores the value and writes it to disk immediately — there is no batching and no dirty flag, so an assignment in a tight loop is a file write per iteration.
In a read-only session the value is still stored in memory, so the rest of the run sees the assignment; only the file write is skipped.
@throws kotlinx.serialization.SerializationException if encoding or writing fails
override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) {
val box = resolveBox(thisRef)
val file = fileFor(box, property)
save(box, file, value, property)
this.value = value
loaded = true
}
The write, and the only place this class touches disk.
Read-only sessions are refused here rather than at the two call sites so that neither
getValue's write-the-default path nor setValue can reopen the hole. Refusing is a return
and not a throw: saved {} is stdlib a user's sketch calls, and a read-only session that
threw on var n by saved { 0 } would stop the box running rather than stop it saving. The
caller still caches the value in memory, so the run behaves normally and simply forgets on
exit — which is what the client's read-only banner now promises.
private fun save(box: Box, file: File, value: T, property: KProperty<*>) {
if (ReadOnlyDocuments.blocksWritesFrom(box)) {
ReadOnlyDocuments.refuseWrite("saved{}:${property.name}", file.path)
return
}
try {
file.parentFile?.mkdirs()
file.safeSave(FieldJson.encodeToString(serializer, value), atomic = true)
} catch (t: Throwable) {
throw SerializationException(
"Couldn't save '${property.name}' to ${file.absolutePath}: ${t.message}",
t
)
}
}
private fun fileFor(box: Box, property: KProperty<*>): File {
require(box.isInsideInitialized()) {
"saved { ... } requires an initialized box/session for '${property.name}'"
}
val documentDir = File(box.inside.filename)
val boxDir = File(documentDir, box.id)
return File(boxDir, "${property.name}.json")
}
private fun resolveBox(thisRef: Any?): Box {
return (thisRef as? Box)
?: Box.context.get()
?: error("saved { ... } requires an active Box context")
}
}
inline fun <reified T> saved(noinline defaultValue: () -> T): SavedJsonValue<T> {
var n by saved { 0 } — a box property that persists across runs, restarts and reopening the document.
The shortest way to give a sketch memory: assign to it and it is on disk; read it next session and it is still
there. defaultValue is used only when there is no file yet, and is then written out.
T must be serializable by kotlinx.serialization — a @Serializable class, or one of the types Field's
FieldJson already knows. The failure for an unsupported type is a SerializationException at the first read
or write, not at compile time.
@OptIn(ExperimentalSerializationApi::class)
inline fun <reified T> saved(noinline defaultValue: () -> T): SavedJsonValue<T> {
@Suppress("UNCHECKED_CAST")
val serializer = FieldJson.serializersModule.serializer(typeOf<T>()) as KSerializer<T>
return SavedJsonValue(serializer, defaultValue)
}
inline fun <reified T> saved(): SavedJsonValue<T>
val log by saved<List<String>>() — saved without a default, for the collection types that have an obvious
empty value.
Supported only for List, Set, Map and their mutable forms; anything else throws on first use rather
than at compile time, with a message saying an explicit default is required. When in doubt use the overload
that takes one.
@OptIn(ExperimentalSerializationApi::class)
inline fun <reified T> saved(): SavedJsonValue<T> {
val type = typeOf<T>()
@Suppress("UNCHECKED_CAST")
val serializer = FieldJson.serializersModule.serializer(type) as KSerializer<T>
return SavedJsonValue(serializer) { defaultSavedValue(type) as T }
}
internal fun defaultSavedValue(type: KType): Any
@PublishedApi
internal fun defaultSavedValue(type: KType): Any {
return when (type.classifier) {
List::class -> emptyList<Any?>()
Set::class -> emptySet<Any?>()
Map::class -> emptyMap<Any?, Any?>()
MutableList::class -> mutableListOf<Any?>()
MutableSet::class -> mutableSetOf<Any?>()
MutableMap::class -> mutableMapOf<Any?, Any?>()
else -> error("saved() requires an explicit default for ${type}. Only List, Set, and Map have built-in defaults.")
}
}