@g9tm/lynxcontrols-native 0.0.0-stage → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +85 -2
- package/android/build.gradle.kts +45 -0
- package/android/consumer-rules.pro +3 -0
- package/android/src/main/AndroidManifest.xml +5 -0
- package/android/src/main/java/com/g9lynx/g9native/G9Bridge.kt +67 -0
- package/android/src/main/java/com/g9lynx/g9native/G9HostModule.kt +159 -0
- package/android/src/main/java/com/g9lynx/g9native/G9SqliteModule.kt +49 -0
- package/android/src/main/java/com/g9lynx/g9native/generated/G9HostModuleSpec.java +48 -0
- package/android/src/main/java/com/g9lynx/g9native/generated/G9SqliteModuleSpec.java +41 -0
- package/android/src/main/java/com/g9lynx/g9native/host/core/G9PreferencesCore.kt +100 -0
- package/android/src/main/java/com/g9lynx/g9native/sqlite/androidx/AndroidxSqlEngine.kt +88 -0
- package/android/src/main/java/com/g9lynx/g9native/sqlite/core/G9SqlEngine.kt +61 -0
- package/android/src/main/java/com/g9lynx/g9native/sqlite/core/G9SqliteCore.kt +260 -0
- package/android/src/main/java/com/g9lynx/g9native/sqlite/core/G9SqliteService.kt +131 -0
- package/android/src/main/java/com/g9lynx/g9native/sqlite/core/G9SqliteTransport.kt +125 -0
- package/android/src/main/java/com/g9lynx/g9native/sqlite/core/G9SqliteValues.kt +71 -0
- package/android/src/main/java/com/g9lynx/g9native/video/G9VideoUI.kt +214 -0
- package/android/src/main/java/com/g9lynx/g9native/video/core/G9VideoCore.kt +67 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/driver.d.ts +20 -0
- package/dist/driver.d.ts.map +1 -0
- package/dist/driver.js +159 -0
- package/dist/driver.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/media.d.ts +60 -0
- package/dist/media.d.ts.map +1 -0
- package/dist/media.jsx +119 -0
- package/dist/media.jsx.map +1 -0
- package/dist/protocol.d.ts +115 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +1 -0
- package/dist/services.d.ts +36 -0
- package/dist/services.d.ts.map +1 -0
- package/dist/services.js +111 -0
- package/dist/services.js.map +1 -0
- package/dist/transport.d.ts +10 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +77 -0
- package/dist/transport.js.map +1 -0
- package/generated/G9HostModule.ts +71 -0
- package/generated/G9SqliteModule.ts +68 -0
- package/ios/Tests/G9NativeTestSuite.h +25 -0
- package/ios/Tests/G9NativeTestSuite.m +560 -0
- package/ios/Tests/G9NativeTests.m +52 -0
- package/ios/g9lynx-native.podspec +29 -0
- package/ios/src/G9HostModule.h +14 -0
- package/ios/src/G9HostModule.m +121 -0
- package/ios/src/G9SqliteModule.h +14 -0
- package/ios/src/G9SqliteModule.m +167 -0
- package/ios/src/G9VideoUI.h +21 -0
- package/ios/src/G9VideoUI.m +250 -0
- package/ios/src/core/G9HostPaths.h +20 -0
- package/ios/src/core/G9HostPaths.m +23 -0
- package/ios/src/core/G9PreferencesCore.h +43 -0
- package/ios/src/core/G9PreferencesCore.m +114 -0
- package/ios/src/core/G9SqliteCore.h +62 -0
- package/ios/src/core/G9SqliteCore.m +497 -0
- package/ios/src/core/G9SqliteTransport.h +16 -0
- package/ios/src/core/G9SqliteTransport.m +85 -0
- package/ios/src/core/G9VideoCore.h +26 -0
- package/ios/src/core/G9VideoCore.m +37 -0
- package/ios/src/generated/G9HostModuleSpec.h +23 -0
- package/ios/src/generated/G9HostModuleSpec.m +2 -0
- package/ios/src/generated/G9SqliteModuleSpec.h +20 -0
- package/ios/src/generated/G9SqliteModuleSpec.m +2 -0
- package/lynx.lib.json +12 -0
- package/package.json +49 -4
- package/types/platform-native-module.d.ts +46 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
package com.g9lynx.g9native.sqlite.androidx
|
|
2
|
+
|
|
3
|
+
import androidx.sqlite.SQLiteConnection
|
|
4
|
+
import androidx.sqlite.SQLiteException
|
|
5
|
+
import androidx.sqlite.SQLiteStatement
|
|
6
|
+
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
|
7
|
+
import androidx.sqlite.driver.bundled.SQLITE_OPEN_CREATE
|
|
8
|
+
import androidx.sqlite.driver.bundled.SQLITE_OPEN_FULLMUTEX
|
|
9
|
+
import androidx.sqlite.driver.bundled.SQLITE_OPEN_READONLY
|
|
10
|
+
import androidx.sqlite.driver.bundled.SQLITE_OPEN_READWRITE
|
|
11
|
+
import com.g9lynx.g9native.sqlite.core.G9SqlConnection
|
|
12
|
+
import com.g9lynx.g9native.sqlite.core.G9SqlEngine
|
|
13
|
+
import com.g9lynx.g9native.sqlite.core.G9SqlEngineException
|
|
14
|
+
import com.g9lynx.g9native.sqlite.core.G9SqlStatement
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The production engine: androidx.sqlite `BundledSQLiteDriver` (`sqlite-bundled` 2.7.1), which ships its own SQLite so
|
|
18
|
+
* every Android version runs the same engine (framework SQLite on API 26 is 3.18 and lacks UPSERT). The same class runs
|
|
19
|
+
* on the JVM over `sqlite-bundled-jvm` in the contract tests.
|
|
20
|
+
*/
|
|
21
|
+
class AndroidxSqlEngine : G9SqlEngine {
|
|
22
|
+
private val driver = BundledSQLiteDriver()
|
|
23
|
+
|
|
24
|
+
override val name = "androidx-bundled"
|
|
25
|
+
|
|
26
|
+
override fun open(path: String, readOnly: Boolean, create: Boolean): G9SqlConnection {
|
|
27
|
+
val flags =
|
|
28
|
+
(if (readOnly) SQLITE_OPEN_READONLY else SQLITE_OPEN_READWRITE) or
|
|
29
|
+
(if (create && !readOnly) SQLITE_OPEN_CREATE else 0) or
|
|
30
|
+
SQLITE_OPEN_FULLMUTEX
|
|
31
|
+
return translate { Connection(driver.open(path, flags)) }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
private class Connection(private val connection: SQLiteConnection) : G9SqlConnection {
|
|
35
|
+
override fun prepare(sql: String): G9SqlStatement = translate { Statement(connection.prepare(sql)) }
|
|
36
|
+
|
|
37
|
+
override fun close() = connection.close()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private class Statement(private val statement: SQLiteStatement) : G9SqlStatement {
|
|
41
|
+
override fun bindNull(index: Int) = translate { statement.bindNull(index) }
|
|
42
|
+
|
|
43
|
+
override fun bindLong(index: Int, value: Long) = translate { statement.bindLong(index, value) }
|
|
44
|
+
|
|
45
|
+
override fun bindDouble(index: Int, value: Double) = translate { statement.bindDouble(index, value) }
|
|
46
|
+
|
|
47
|
+
override fun bindText(index: Int, value: String) = translate { statement.bindText(index, value) }
|
|
48
|
+
|
|
49
|
+
override fun bindBlob(index: Int, value: ByteArray) = translate { statement.bindBlob(index, value) }
|
|
50
|
+
|
|
51
|
+
override fun step(): Boolean = translate { statement.step() }
|
|
52
|
+
|
|
53
|
+
override fun columnCount(): Int = statement.getColumnCount()
|
|
54
|
+
|
|
55
|
+
override fun columnName(index: Int): String = statement.getColumnName(index)
|
|
56
|
+
|
|
57
|
+
// androidx returns the SQLITE_INTEGER … SQLITE_NULL values that G9SqlType mirrors.
|
|
58
|
+
override fun columnType(index: Int): Int = statement.getColumnType(index)
|
|
59
|
+
|
|
60
|
+
override fun getLong(index: Int): Long = statement.getLong(index)
|
|
61
|
+
|
|
62
|
+
override fun getDouble(index: Int): Double = statement.getDouble(index)
|
|
63
|
+
|
|
64
|
+
override fun getText(index: Int): String = statement.getText(index)
|
|
65
|
+
|
|
66
|
+
override fun getBlob(index: Int): ByteArray = statement.getBlob(index)
|
|
67
|
+
|
|
68
|
+
override fun reset() = translate { statement.reset() }
|
|
69
|
+
|
|
70
|
+
override fun clearBindings() = translate { statement.clearBindings() }
|
|
71
|
+
|
|
72
|
+
override fun close() = statement.close()
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
companion object {
|
|
76
|
+
private val CODE = Regex("""[Ee]rror code: (\d+)""")
|
|
77
|
+
|
|
78
|
+
/** androidx reports failures as `SQLiteException("Error code: N, message: …")`; N is SQLite's result code. */
|
|
79
|
+
inline fun <T> translate(block: () -> T): T =
|
|
80
|
+
try {
|
|
81
|
+
block()
|
|
82
|
+
} catch (error: SQLiteException) {
|
|
83
|
+
throw G9SqlEngineException(codeOf(error.message), error.message ?: "SQLite error")
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
fun codeOf(message: String?): Int = message?.let { CODE.find(it)?.groupValues?.get(1)?.toIntOrNull() } ?: 1
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
package com.g9lynx.g9native.sqlite.core
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The SQLite engine the core runs on, shaped like the androidx.sqlite KMP API (`SQLiteDriver` / `SQLiteConnection` /
|
|
5
|
+
* `SQLiteStatement`) so the Android adapter is one call per method. Production: [com.g9lynx.g9native.sqlite.AndroidxSqlEngine]
|
|
6
|
+
* over `BundledSQLiteDriver`. JVM tests: the same adapter over `sqlite-bundled-jvm`, or a JDBC adapter where Google
|
|
7
|
+
* Maven is unreachable (plan/execution/Cloud-Runbook.md §8).
|
|
8
|
+
*/
|
|
9
|
+
interface G9SqlEngine {
|
|
10
|
+
/** Short name for reports (`androidx-bundled`, `jdbc`). */
|
|
11
|
+
val name: String
|
|
12
|
+
|
|
13
|
+
/** Opens a connection. The core has already checked existence and created parent directories. */
|
|
14
|
+
fun open(path: String, readOnly: Boolean, create: Boolean): G9SqlConnection
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface G9SqlConnection : AutoCloseable {
|
|
18
|
+
fun prepare(sql: String): G9SqlStatement
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** One prepared statement. Bind indexes are 1-based, column indexes 0-based (as in SQLite and androidx). */
|
|
22
|
+
interface G9SqlStatement : AutoCloseable {
|
|
23
|
+
fun bindNull(index: Int)
|
|
24
|
+
fun bindLong(index: Int, value: Long)
|
|
25
|
+
fun bindDouble(index: Int, value: Double)
|
|
26
|
+
fun bindText(index: Int, value: String)
|
|
27
|
+
fun bindBlob(index: Int, value: ByteArray)
|
|
28
|
+
|
|
29
|
+
/** Advances; `true` while a row is available. Throws [G9SqlEngineException]. */
|
|
30
|
+
fun step(): Boolean
|
|
31
|
+
|
|
32
|
+
fun columnCount(): Int
|
|
33
|
+
fun columnName(index: Int): String
|
|
34
|
+
|
|
35
|
+
/** One of [G9SqlType]; valid after a `step()` that returned `true`. */
|
|
36
|
+
fun columnType(index: Int): Int
|
|
37
|
+
fun getLong(index: Int): Long
|
|
38
|
+
fun getDouble(index: Int): Double
|
|
39
|
+
fun getText(index: Int): String
|
|
40
|
+
fun getBlob(index: Int): ByteArray
|
|
41
|
+
|
|
42
|
+
fun reset()
|
|
43
|
+
fun clearBindings()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** SQLite fundamental datatypes (`SQLITE_INTEGER` … `SQLITE_NULL`, the androidx `SQLITE_DATA_*` values). */
|
|
47
|
+
object G9SqlType {
|
|
48
|
+
const val INTEGER = 1
|
|
49
|
+
const val FLOAT = 2
|
|
50
|
+
const val TEXT = 3
|
|
51
|
+
const val BLOB = 4
|
|
52
|
+
const val NULL = 5
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** An engine failure with SQLite's (extended) result code. */
|
|
56
|
+
class G9SqlEngineException(val resultCode: Int, message: String) : RuntimeException(message)
|
|
57
|
+
|
|
58
|
+
/** Runs a statement that returns nothing (or whose rows are ignored, as a PRAGMA's). */
|
|
59
|
+
fun G9SqlConnection.exec(sql: String) {
|
|
60
|
+
prepare(sql).use { statement -> while (statement.step()) Unit }
|
|
61
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
package com.g9lynx.g9native.sqlite.core
|
|
2
|
+
|
|
3
|
+
import java.io.File
|
|
4
|
+
import java.util.concurrent.ConcurrentHashMap
|
|
5
|
+
import java.util.concurrent.atomic.AtomicLong
|
|
6
|
+
|
|
7
|
+
/** One statement of a batch: SQL and bridge-shaped parameters (see [fromBridge]). */
|
|
8
|
+
class G9Statement(val sql: String, val params: List<Any?>)
|
|
9
|
+
|
|
10
|
+
/** One statement's outcome. Row values are `null`, `Long`, `Double`, `String` or `ByteArray`. */
|
|
11
|
+
class G9SqlResult(
|
|
12
|
+
val columns: List<String>,
|
|
13
|
+
val rows: List<Array<Any?>>,
|
|
14
|
+
val changes: Long,
|
|
15
|
+
val lastInsertRowId: Long,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
class G9OpenOptions(
|
|
19
|
+
val path: String,
|
|
20
|
+
val readOnly: Boolean = false,
|
|
21
|
+
val create: Boolean = false,
|
|
22
|
+
/** PRAGMA statements built by `pragmaStatements` on the JS side. */
|
|
23
|
+
val pragmas: List<String> = emptyList(),
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
class G9RunOptions(val tx: String? = null, val maxRows: Int? = null, val atomic: Boolean = false)
|
|
27
|
+
|
|
28
|
+
class G9Capabilities(
|
|
29
|
+
val sqliteVersion: String,
|
|
30
|
+
val maxVariables: Int,
|
|
31
|
+
val json1: Boolean,
|
|
32
|
+
val returning: Boolean,
|
|
33
|
+
val upsert: Boolean,
|
|
34
|
+
val interrupt: Boolean,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The Android SQLite driver core (plan/specs/06 §4): sessions, transaction tokens, a statement cache per session,
|
|
39
|
+
* value conversion and error mapping, over a [G9SqlEngine]. Not thread-safe per session: [G9SqliteDispatcher] runs
|
|
40
|
+
* every request of one session on that session's single thread. Cancellation applies to queued work only: androidx
|
|
41
|
+
* connections expose no `sqlite3_interrupt` (capabilities report `interrupt: false`).
|
|
42
|
+
*/
|
|
43
|
+
class G9SqliteCore(private val engine: G9SqlEngine, private val statementCacheSize: Int = 64) {
|
|
44
|
+
private class Session(val connection: G9SqlConnection) {
|
|
45
|
+
// Access order: the first entry is the least recently used (evicted and closed past the cache size).
|
|
46
|
+
val statements = LinkedHashMap<String, G9SqlStatement>(16, 0.75f, true)
|
|
47
|
+
var tx: String? = null
|
|
48
|
+
var lastInsertRowId = 0L
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private val sessions = ConcurrentHashMap<String, Session>()
|
|
52
|
+
private val txSessions = ConcurrentHashMap<String, String>()
|
|
53
|
+
private val counter = AtomicLong()
|
|
54
|
+
|
|
55
|
+
val engineName: String
|
|
56
|
+
get() = engine.name
|
|
57
|
+
|
|
58
|
+
/** The session a transaction token belongs to (the dispatcher queues tx work there), or `null`. */
|
|
59
|
+
fun sessionOfTx(tx: String): String? = txSessions[tx]
|
|
60
|
+
|
|
61
|
+
fun capabilities(): G9Capabilities {
|
|
62
|
+
val connection = engine.open(":memory:", readOnly = false, create = true)
|
|
63
|
+
try {
|
|
64
|
+
val version = connection.prepare("SELECT sqlite_version()").use { s -> s.step(); s.getText(0) }
|
|
65
|
+
val parts = version.split('.').map { it.toIntOrNull() ?: 0 }
|
|
66
|
+
val atLeast = { major: Int, minor: Int ->
|
|
67
|
+
(parts.getOrElse(0) { 0 } > major) || (parts.getOrElse(0) { 0 } == major && parts.getOrElse(1) { 0 } >= minor)
|
|
68
|
+
}
|
|
69
|
+
return G9Capabilities(version, 32766, json1 = true, returning = atLeast(3, 35), upsert = atLeast(3, 24), interrupt = false)
|
|
70
|
+
} finally {
|
|
71
|
+
connection.close()
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
fun open(options: G9OpenOptions): String {
|
|
76
|
+
val memory = options.path == "" || options.path == ":memory:"
|
|
77
|
+
if (!memory) {
|
|
78
|
+
val file = File(options.path)
|
|
79
|
+
if (!options.create && !file.exists()) {
|
|
80
|
+
throw G9SqliteException("not-found", "Database file does not exist: ${options.path}")
|
|
81
|
+
}
|
|
82
|
+
if (options.create) file.absoluteFile.parentFile?.mkdirs()
|
|
83
|
+
}
|
|
84
|
+
val connection =
|
|
85
|
+
try {
|
|
86
|
+
engine.open(if (memory) ":memory:" else options.path, options.readOnly, options.create)
|
|
87
|
+
} catch (error: Throwable) {
|
|
88
|
+
throw toG9Error(error)
|
|
89
|
+
}
|
|
90
|
+
for (sql in options.pragmas) {
|
|
91
|
+
try {
|
|
92
|
+
connection.exec(sql)
|
|
93
|
+
} catch (error: Throwable) {
|
|
94
|
+
// journal_mode cannot change on a read-only or in-memory database; tuning never fails an open.
|
|
95
|
+
if (!sql.startsWith("PRAGMA journal_mode")) {
|
|
96
|
+
connection.close()
|
|
97
|
+
throw toG9Error(error, sql)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
val token = "s${counter.incrementAndGet()}"
|
|
102
|
+
sessions[token] = Session(connection)
|
|
103
|
+
return token
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private fun session(token: String): Session =
|
|
107
|
+
sessions[token] ?: misuse("The database session is closed or unknown.")
|
|
108
|
+
|
|
109
|
+
private fun prepare(session: Session, sql: String): G9SqlStatement {
|
|
110
|
+
session.statements[sql]?.let { return it }
|
|
111
|
+
val statement = session.connection.prepare(sql)
|
|
112
|
+
session.statements[sql] = statement
|
|
113
|
+
if (session.statements.size > statementCacheSize) {
|
|
114
|
+
val eldest = session.statements.entries.iterator().next()
|
|
115
|
+
session.statements.remove(eldest.key)
|
|
116
|
+
runCatching { eldest.value.close() }
|
|
117
|
+
}
|
|
118
|
+
return statement
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private fun bind(statement: G9SqlStatement, index: Int, raw: Any?, sql: String) {
|
|
122
|
+
when (val value = fromBridge(raw)) {
|
|
123
|
+
null -> statement.bindNull(index)
|
|
124
|
+
is String -> {
|
|
125
|
+
if (value.indexOf('\u0000') >= 0) misuse("Parameter $index is text containing NUL; use a BLOB.", sql)
|
|
126
|
+
statement.bindText(index, value)
|
|
127
|
+
}
|
|
128
|
+
is Long -> statement.bindLong(index, value)
|
|
129
|
+
is Double ->
|
|
130
|
+
when {
|
|
131
|
+
value.isNaN() -> misuse("Parameter $index is NaN.", sql)
|
|
132
|
+
value == Math.rint(value) && Math.abs(value) <= MAX_SAFE_INTEGER.toDouble() -> statement.bindLong(index, value.toLong())
|
|
133
|
+
else -> statement.bindDouble(index, value)
|
|
134
|
+
}
|
|
135
|
+
is ByteArray -> statement.bindBlob(index, value)
|
|
136
|
+
is G9Int64 -> statement.bindLong(index, value.decimal.toLongOrNull() ?: misuse("Parameter $index is not a 64-bit integer.", sql))
|
|
137
|
+
G9Undefined -> misuse("undefined is not a SQL value (use null).", sql)
|
|
138
|
+
else -> misuse("Parameter $index is not a SQL value (${value::class.java.simpleName}).", sql)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private fun read(statement: G9SqlStatement, column: Int): Any? =
|
|
143
|
+
when (statement.columnType(column)) {
|
|
144
|
+
G9SqlType.NULL -> null
|
|
145
|
+
G9SqlType.INTEGER -> statement.getLong(column)
|
|
146
|
+
G9SqlType.FLOAT -> statement.getDouble(column)
|
|
147
|
+
G9SqlType.TEXT -> statement.getText(column)
|
|
148
|
+
else -> statement.getBlob(column)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private fun execute(session: Session, statement: G9Statement, maxRows: Int?): G9SqlResult {
|
|
152
|
+
val prepared = prepare(session, statement.sql)
|
|
153
|
+
try {
|
|
154
|
+
statement.params.forEachIndexed { i, value -> bind(prepared, i + 1, value, statement.sql) }
|
|
155
|
+
val count = prepared.columnCount()
|
|
156
|
+
if (count > 0) {
|
|
157
|
+
val columns = List(count) { prepared.columnName(it) }
|
|
158
|
+
val rows = ArrayList<Array<Any?>>()
|
|
159
|
+
while (prepared.step()) {
|
|
160
|
+
if (maxRows != null && rows.size >= maxRows) {
|
|
161
|
+
throw G9SqliteException("too-many-rows", "The statement returned more than $maxRows rows.", sql = statement.sql)
|
|
162
|
+
}
|
|
163
|
+
rows.add(Array(count) { read(prepared, it) })
|
|
164
|
+
}
|
|
165
|
+
return G9SqlResult(columns, rows, 0, session.lastInsertRowId)
|
|
166
|
+
}
|
|
167
|
+
while (prepared.step()) Unit
|
|
168
|
+
val (changes, rowId) =
|
|
169
|
+
session.connection.prepare("SELECT changes(), last_insert_rowid()").use { s ->
|
|
170
|
+
s.step()
|
|
171
|
+
s.getLong(0) to s.getLong(1)
|
|
172
|
+
}
|
|
173
|
+
session.lastInsertRowId = rowId
|
|
174
|
+
return G9SqlResult(emptyList(), emptyList(), changes, rowId)
|
|
175
|
+
} finally {
|
|
176
|
+
runCatching { prepared.reset() }
|
|
177
|
+
runCatching { prepared.clearBindings() }
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
fun run(token: String, statements: List<G9Statement>, options: G9RunOptions): List<G9SqlResult> {
|
|
182
|
+
val session = session(token)
|
|
183
|
+
if (options.tx != null && session.tx != options.tx) {
|
|
184
|
+
misuse("The transaction is finished or belongs to another session.")
|
|
185
|
+
}
|
|
186
|
+
if (options.tx == null && session.tx != null) misuse("A transaction is open on this session; pass its token (tx).")
|
|
187
|
+
val atomic = options.atomic && options.tx == null && statements.isNotEmpty()
|
|
188
|
+
if (atomic) session.connection.exec("BEGIN")
|
|
189
|
+
val results = ArrayList<G9SqlResult>(statements.size)
|
|
190
|
+
try {
|
|
191
|
+
for (statement in statements) {
|
|
192
|
+
try {
|
|
193
|
+
results.add(execute(session, statement, options.maxRows))
|
|
194
|
+
} catch (error: Throwable) {
|
|
195
|
+
throw toG9Error(error, statement.sql, results.size)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (atomic) session.connection.exec("COMMIT")
|
|
199
|
+
} catch (error: Throwable) {
|
|
200
|
+
if (atomic) runCatching { session.connection.exec("ROLLBACK") } // SQLite may already have rolled back.
|
|
201
|
+
throw toG9Error(error)
|
|
202
|
+
}
|
|
203
|
+
return results
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
fun begin(token: String, mode: String): String {
|
|
207
|
+
val session = session(token)
|
|
208
|
+
if (session.tx != null) misuse("A transaction is already open on this session.")
|
|
209
|
+
try {
|
|
210
|
+
session.connection.exec(if (mode == "immediate") "BEGIN IMMEDIATE" else "BEGIN DEFERRED")
|
|
211
|
+
} catch (error: Throwable) {
|
|
212
|
+
throw toG9Error(error)
|
|
213
|
+
}
|
|
214
|
+
val tx = "x${counter.incrementAndGet()}"
|
|
215
|
+
session.tx = tx
|
|
216
|
+
txSessions[tx] = token
|
|
217
|
+
return tx
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private fun sessionForTx(tx: String): Session {
|
|
221
|
+
val token = txSessions[tx] ?: misuse("The transaction is finished or unknown.")
|
|
222
|
+
val session = sessions[token]
|
|
223
|
+
if (session == null || session.tx != tx) misuse("The transaction is finished or unknown.")
|
|
224
|
+
return session
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
fun commit(tx: String) {
|
|
228
|
+
val session = sessionForTx(tx)
|
|
229
|
+
try {
|
|
230
|
+
session.connection.exec("COMMIT")
|
|
231
|
+
} catch (error: Throwable) {
|
|
232
|
+
// A failed COMMIT (busy) leaves the transaction open: the caller may retry or roll back.
|
|
233
|
+
throw toG9Error(error)
|
|
234
|
+
}
|
|
235
|
+
session.tx = null
|
|
236
|
+
txSessions.remove(tx)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
fun rollback(tx: String) {
|
|
240
|
+
val session = sessionForTx(tx)
|
|
241
|
+
session.tx = null
|
|
242
|
+
txSessions.remove(tx)
|
|
243
|
+
// SQLite may already have rolled back on an error; the transaction is over either way.
|
|
244
|
+
runCatching { session.connection.exec("ROLLBACK") }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
fun close(token: String) {
|
|
248
|
+
val session = sessions.remove(token) ?: misuse("The database session is closed or unknown.")
|
|
249
|
+
session.tx?.let { txSessions.remove(it) }
|
|
250
|
+
session.tx = null
|
|
251
|
+
for (statement in session.statements.values) runCatching { statement.close() }
|
|
252
|
+
session.statements.clear()
|
|
253
|
+
session.connection.close()
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Closes every session (module teardown). */
|
|
257
|
+
fun closeAll() {
|
|
258
|
+
for (token in sessions.keys.toList()) runCatching { close(token) }
|
|
259
|
+
}
|
|
260
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
package com.g9lynx.g9native.sqlite.core
|
|
2
|
+
|
|
3
|
+
import java.util.concurrent.ConcurrentHashMap
|
|
4
|
+
import java.util.concurrent.ExecutorService
|
|
5
|
+
import java.util.concurrent.LinkedBlockingQueue
|
|
6
|
+
import java.util.concurrent.RejectedExecutionException
|
|
7
|
+
import java.util.concurrent.ThreadPoolExecutor
|
|
8
|
+
import java.util.concurrent.TimeUnit
|
|
9
|
+
|
|
10
|
+
/** Where a reply goes (the Lynx module invokes the JS callback; Lynx marshals it to the JS thread). */
|
|
11
|
+
typealias G9Reply = (Map<String, Any?>) -> Unit
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The bridge-facing SQLite service: requests arrive as bridge data (`Map` / `List` from `toHashMap()` /
|
|
15
|
+
* `toArrayList()`), run on one single-thread executor per session (all statements of a connection on one thread) with
|
|
16
|
+
* a bounded queue, and reply with an envelope `{ ok, value | error }`. Opening and capability probes share one thread.
|
|
17
|
+
* Pure JVM: the Lynx module is a thin adapter around it.
|
|
18
|
+
*/
|
|
19
|
+
class G9SqliteService(private val core: G9SqliteCore, private val queueCapacity: Int = 256) {
|
|
20
|
+
private val executors = ConcurrentHashMap<String, ExecutorService>()
|
|
21
|
+
private val shared = newExecutor("g9-sqlite-open")
|
|
22
|
+
@Volatile private var shutDown = false
|
|
23
|
+
|
|
24
|
+
private fun newExecutor(name: String): ThreadPoolExecutor =
|
|
25
|
+
ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, LinkedBlockingQueue(queueCapacity)) { runnable ->
|
|
26
|
+
Thread(runnable, name).apply { isDaemon = true }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
private fun executorFor(session: String?): ExecutorService =
|
|
30
|
+
if (session == null) shared else executors.computeIfAbsent(session) { newExecutor("g9-sqlite-$it") }
|
|
31
|
+
|
|
32
|
+
private fun submit(session: String?, reply: G9Reply, work: () -> Any?) {
|
|
33
|
+
if (shutDown) {
|
|
34
|
+
reply(G9SqliteTransport.failure(G9SqliteException("misuse", "The SQLite module is shut down.")))
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
executorFor(session).execute {
|
|
39
|
+
val envelope =
|
|
40
|
+
try {
|
|
41
|
+
G9SqliteTransport.ok(work())
|
|
42
|
+
} catch (error: Throwable) {
|
|
43
|
+
G9SqliteTransport.failure(error)
|
|
44
|
+
}
|
|
45
|
+
reply(envelope)
|
|
46
|
+
}
|
|
47
|
+
} catch (_: RejectedExecutionException) {
|
|
48
|
+
reply(G9SqliteTransport.failure(G9SqliteException("busy", "Too many queued requests for this database session.")))
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
fun capabilities(reply: G9Reply) =
|
|
53
|
+
submit(null, reply) {
|
|
54
|
+
val c = core.capabilities()
|
|
55
|
+
mapOf(
|
|
56
|
+
"sqliteVersion" to c.sqliteVersion,
|
|
57
|
+
"maxVariables" to c.maxVariables.toDouble(),
|
|
58
|
+
"json1" to c.json1,
|
|
59
|
+
"returning" to c.returning,
|
|
60
|
+
"upsert" to c.upsert,
|
|
61
|
+
"interrupt" to c.interrupt,
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
fun open(options: Map<*, *>, reply: G9Reply) =
|
|
66
|
+
submit(null, reply) {
|
|
67
|
+
core.open(
|
|
68
|
+
G9OpenOptions(
|
|
69
|
+
path = options["path"] as? String ?: misuse("open needs a path."),
|
|
70
|
+
readOnly = options["readOnly"] == true,
|
|
71
|
+
create = options["create"] == true,
|
|
72
|
+
pragmas = (options["pragmas"] as? List<*>)?.map { it as String } ?: emptyList(),
|
|
73
|
+
),
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
fun run(session: String, statements: List<*>, options: Map<*, *>, reply: G9Reply) =
|
|
78
|
+
submit(session, reply) {
|
|
79
|
+
val batch =
|
|
80
|
+
statements.map { raw ->
|
|
81
|
+
val s = raw as? Map<*, *> ?: misuse("A statement must be an object { sql, params }.")
|
|
82
|
+
G9Statement(s["sql"] as? String ?: misuse("A statement needs sql."), (s["params"] as? List<*>) ?: emptyList<Any?>())
|
|
83
|
+
}
|
|
84
|
+
val results =
|
|
85
|
+
core.run(
|
|
86
|
+
session,
|
|
87
|
+
batch,
|
|
88
|
+
G9RunOptions(
|
|
89
|
+
tx = options["tx"] as? String,
|
|
90
|
+
maxRows = (options["maxRows"] as? Number)?.toInt(),
|
|
91
|
+
atomic = options["atomic"] == true,
|
|
92
|
+
),
|
|
93
|
+
)
|
|
94
|
+
G9SqliteTransport.encode(results, options["transport"] as? String ?: G9SqliteTransport.COLUMNAR)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
fun begin(session: String, mode: String, reply: G9Reply) = submit(session, reply) { core.begin(session, mode) }
|
|
98
|
+
|
|
99
|
+
fun commit(tx: String, reply: G9Reply) =
|
|
100
|
+
submit(core.sessionOfTx(tx), reply) {
|
|
101
|
+
core.commit(tx)
|
|
102
|
+
null
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
fun rollback(tx: String, reply: G9Reply) =
|
|
106
|
+
submit(core.sessionOfTx(tx), reply) {
|
|
107
|
+
core.rollback(tx)
|
|
108
|
+
null
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
fun close(session: String, reply: G9Reply) =
|
|
112
|
+
submit(session, reply) {
|
|
113
|
+
try {
|
|
114
|
+
core.close(session)
|
|
115
|
+
} finally {
|
|
116
|
+
// This task is the executor's last: later requests for the token get a fresh executor and a `misuse` reply.
|
|
117
|
+
executors.remove(session)?.shutdown()
|
|
118
|
+
}
|
|
119
|
+
null
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Closes every database and stops the threads (module destroy). Queued requests still run first. */
|
|
123
|
+
fun shutdown() {
|
|
124
|
+
shutDown = true
|
|
125
|
+
for (executor in executors.values) executor.shutdown()
|
|
126
|
+
for (executor in executors.values) executor.awaitTermination(5, TimeUnit.SECONDS)
|
|
127
|
+
executors.clear()
|
|
128
|
+
shared.shutdown()
|
|
129
|
+
core.closeAll()
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
package com.g9lynx.g9native.sqlite.core
|
|
2
|
+
|
|
3
|
+
import java.nio.ByteBuffer
|
|
4
|
+
import java.nio.ByteOrder
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* How results cross to JS (plan/specs/06 §6). The contract is fixed; these bytes are not.
|
|
8
|
+
*
|
|
9
|
+
* - **C, `columnar` (default):** per result `columns`, `rowCount`, `changes`, `lastInsertRowId`, and the cells in row
|
|
10
|
+
* order as one tag byte each in `types` (0 null, 1 number, 2 int64 text, 3 text, 4 blob); numbers (and each blob's
|
|
11
|
+
* length) as little-endian float64 in `numbers`; text and int64 decimals in `strings`; blob bytes back to back in
|
|
12
|
+
* `blobs`. No per-cell boxing, no UTF-8 decoding in JS.
|
|
13
|
+
* - **A, `rows`:** `rows` as arrays of tagged cells (`null`, number, string, `{ int64 }`, ArrayBuffer).
|
|
14
|
+
*
|
|
15
|
+
* Output is plain Kotlin data (`Map`, `List`, `String`, `Double`, `Boolean`, `ByteArray`, `null`); the Lynx module
|
|
16
|
+
* copies it into bridge maps.
|
|
17
|
+
*/
|
|
18
|
+
object G9SqliteTransport {
|
|
19
|
+
const val COLUMNAR = "columnar"
|
|
20
|
+
const val ROWS = "rows"
|
|
21
|
+
|
|
22
|
+
const val TAG_NULL: Byte = 0
|
|
23
|
+
const val TAG_NUMBER: Byte = 1
|
|
24
|
+
const val TAG_INT64: Byte = 2
|
|
25
|
+
const val TAG_TEXT: Byte = 3
|
|
26
|
+
const val TAG_BLOB: Byte = 4
|
|
27
|
+
|
|
28
|
+
fun wireNumber(value: Long): Any = when (val n = toBridgeNumber(value)) {
|
|
29
|
+
is G9Int64 -> mapOf("int64" to n.decimal)
|
|
30
|
+
else -> n
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
fun encode(results: List<G9SqlResult>, transport: String): Map<String, Any?> =
|
|
34
|
+
mapOf(
|
|
35
|
+
"transport" to (if (transport == ROWS) ROWS else COLUMNAR),
|
|
36
|
+
"results" to results.map { if (transport == ROWS) rows(it) else columnar(it) },
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
fun rows(result: G9SqlResult): Map<String, Any?> =
|
|
40
|
+
mapOf(
|
|
41
|
+
"columns" to result.columns,
|
|
42
|
+
"rows" to result.rows.map { row -> row.map { cell -> if (cell is Long) wireNumber(cell) else cell } },
|
|
43
|
+
"changes" to result.changes.toDouble(),
|
|
44
|
+
"lastInsertRowId" to wireNumber(result.lastInsertRowId),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
fun columnar(result: G9SqlResult): Map<String, Any?> {
|
|
48
|
+
val cells = result.rows.sumOf { it.size }
|
|
49
|
+
val types = ByteArray(cells)
|
|
50
|
+
var numberCount = 0
|
|
51
|
+
var blobBytes = 0
|
|
52
|
+
for (row in result.rows) {
|
|
53
|
+
for (cell in row) {
|
|
54
|
+
when (cell) {
|
|
55
|
+
is Double -> numberCount++
|
|
56
|
+
is Long -> if (toBridgeNumber(cell) is Double) numberCount++
|
|
57
|
+
is ByteArray -> {
|
|
58
|
+
numberCount++
|
|
59
|
+
blobBytes += cell.size
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
val numbers = ByteBuffer.allocate(numberCount * 8).order(ByteOrder.LITTLE_ENDIAN)
|
|
65
|
+
val blobs = ByteBuffer.allocate(blobBytes)
|
|
66
|
+
val strings = ArrayList<String>()
|
|
67
|
+
var i = 0
|
|
68
|
+
for (row in result.rows) {
|
|
69
|
+
for (cell in row) {
|
|
70
|
+
types[i++] =
|
|
71
|
+
when (cell) {
|
|
72
|
+
null -> TAG_NULL
|
|
73
|
+
is Double -> {
|
|
74
|
+
numbers.putDouble(cell)
|
|
75
|
+
TAG_NUMBER
|
|
76
|
+
}
|
|
77
|
+
is Long ->
|
|
78
|
+
when (val n = toBridgeNumber(cell)) {
|
|
79
|
+
is Double -> {
|
|
80
|
+
numbers.putDouble(n)
|
|
81
|
+
TAG_NUMBER
|
|
82
|
+
}
|
|
83
|
+
else -> {
|
|
84
|
+
strings.add((n as G9Int64).decimal)
|
|
85
|
+
TAG_INT64
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
is String -> {
|
|
89
|
+
strings.add(cell)
|
|
90
|
+
TAG_TEXT
|
|
91
|
+
}
|
|
92
|
+
is ByteArray -> {
|
|
93
|
+
numbers.putDouble(cell.size.toDouble())
|
|
94
|
+
blobs.put(cell)
|
|
95
|
+
TAG_BLOB
|
|
96
|
+
}
|
|
97
|
+
else -> throw IllegalStateException("Unexpected cell ${cell::class.java.simpleName}")
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return mapOf(
|
|
102
|
+
"columns" to result.columns,
|
|
103
|
+
"rowCount" to result.rows.size.toDouble(),
|
|
104
|
+
"changes" to result.changes.toDouble(),
|
|
105
|
+
"lastInsertRowId" to wireNumber(result.lastInsertRowId),
|
|
106
|
+
"types" to types,
|
|
107
|
+
"numbers" to numbers.array(),
|
|
108
|
+
"strings" to strings,
|
|
109
|
+
"blobs" to blobs.array(),
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
fun ok(value: Any?): Map<String, Any?> = mapOf("ok" to true, "value" to value)
|
|
114
|
+
|
|
115
|
+
fun failure(error: Throwable): Map<String, Any?> {
|
|
116
|
+
val e = toG9Error(error)
|
|
117
|
+
val fields = LinkedHashMap<String, Any?>()
|
|
118
|
+
fields["code"] = e.code
|
|
119
|
+
fields["message"] = e.message ?: e.code
|
|
120
|
+
e.sqliteCode?.let { fields["sqliteCode"] = it.toDouble() }
|
|
121
|
+
e.sql?.let { fields["sql"] = it }
|
|
122
|
+
e.completedStatements?.let { fields["completedStatements"] = it.toDouble() }
|
|
123
|
+
return mapOf("ok" to false, "error" to fields)
|
|
124
|
+
}
|
|
125
|
+
}
|