# Qbix Framework — LLM Coding Primer (v2) Comprehensive, authoritative reference for generating correct Qbix code. Read entirely before writing any PHP, JS, or config. --- ## 1. Plugin Directory Structure ``` MyPlugin/ config/ plugin.json ← plugin declaration + config handlers/ MyPlugin/ action/ post.php ← POST MyPlugin/action response/ slotName.php ← GET slot "slotName" for MyPlugin/action classes/ MyPlugin.php ← static methods class MyPlugin/ ClassName.php ← ORM model or subclass Base/ ClassName.php ← NEVER edit — autogenerated from DB schema web/ MyPlugin.js ← tool registration (required) js/tools/toolname.js ← Q.Tool.define("MyPlugin/toolname", ...) css/tools/toolname.css text/ MyPlugin/content/en.json ← i18n strings → tool.text.* views/ MyPlugin/content/pagename.php ← page slot views MyPlugin/tool/toolname.php ← PHP tool renderer (optional) scripts/ MyPlugin/ 1.0-MyPlugin.mysql ← DB schema, run by installer ``` --- ## 2. PHP Request Dispatch Pipeline When a request arrives, events fire in this order: ``` Q/prepare → early hook (rarely used) Q/validate → validate input Q/reroute → forward() if needed Q/objects → create PHP objects Q/$verb → handle POST/PUT/DELETE (write to DB here) Q/analytics → logging Q/response → produce output (echo here only) ``` **Only echo/print inside `Q/response` handlers.** For POST handlers, Qbix fires `MyPlugin/action/post` automatically — just implement `MyPlugin_action_post()`. --- ## 3. PHP Handlers — Naming Rules **CRITICAL: action names have NO slashes. One action word only.** | Request | File | Function | |---------|------|----------| | POST `MyPlugin/vote` | `handlers/MyPlugin/vote/post.php` | `MyPlugin_vote_post()` | | GET slot `things` for `MyPlugin/items` | `handlers/MyPlugin/items/response/things.php` | `MyPlugin_items_response_things()` | | Page `content` for `MyPlugin/page` | `handlers/MyPlugin/page/response/content.php` | `MyPlugin_page_response_content()` | **Wrong:** `handlers/MyPlugin/argument/vote/post.php` — slash in action name. **Right:** `handlers/MyPlugin/vote/post.php` Response slot handlers **return** the value — that fills the slot automatically: ```php function MyPlugin_items_response_things() { return array('foo' => 'bar'); // no Q_Response::setSlot needed } ``` POST handlers use `Q_Response::setSlot` after doing work: ```php function MyPlugin_vote_post() { $user = Users::loggedInUser(true); // throws if not logged in $id = Q_Request::requireFields(['id'], true); // throws if field missing $val = Q::ifset($_POST, 'value', 1); // optional with default // ... do work ... Q_Response::setSlot('result', array('ok' => true, 'id' => $id)); } ``` --- ## 4. PHP Events System ```php // Fire an event (calls handlers/MyPlugin/doThing.php → MyPlugin_doThing()) Q::event("MyPlugin/doThing", compact('param1', 'param2')); // Pure event — only runs hooks, no required handler Q::event("MyPlugin/coolEvent", $params, 'before'); // before hooks only Q::event("MyPlugin/coolEvent", $params, 'after'); // after hooks only Q::event("MyPlugin/coolEvent", $params, true); // both hooks // Check if handler exists if (Q::canHandle("MyPlugin/doThing")) { ... } ``` Register hooks in `config/plugin.json`: ```json { "Q": { "handlersBeforeEvent": { "Streams/post/MyPlugin/thing": ["MyPlugin/before/Streams_post_MyPlugin_thing"] }, "handlersAfterEvent": { "Streams/Stream/fetch/MyPlugin/thing": ["MyPlugin/after/Streams_Stream_fetch_MyPlugin_thing"] } } } ``` Hook handler signature — `&$return` to modify return value, return `false` to skip subsequent hooks: ```php function MyPlugin_before_Streams_post_MyPlugin_thing(&$params, &$return) { $stream = $params['stream']; if (!$stream->testWriteLevel('post')) return false; // skip $return = 'custom value'; // overrides event return } ``` --- ## 5. Q.req — Client to Server ```javascript // POST — fires MyPlugin/vote/post on server Q.req('MyPlugin/vote', ['result'], function(err, res) { var msg = Q.firstErrorMessage(err, res && res.errors); if (msg) return console.warn(msg); var result = res.slots.result; }, { method: 'POST', fields: { id: '123', value: 1 } }); // GET — fills slot 'things' via response/things.php Q.req('MyPlugin/items', ['things'], function(err, res) { var things = res.slots.things; }, { fields: { filter: 'recent' } }); ``` `Q.firstErrorMessage(err, res && res.errors)` — always use this for error checking. --- ## 6. Routes — config/app.json ```json { "Q": { "routes": { "claim/:id": { "module": "MyPlugin", "action": "claim" }, ":module/:action": {} } } } ``` Pattern syntax: - `:variable` — matches any single segment - `:variable.literal` — matches `anything.literal` - `:variable[]` — matches any number of remaining segments (last only) - Literal values must match exactly - Routes checked last-to-first; `routes@start` checked first, `routes@end` last - `null` value → route is skipped Unrouting (URI → URL): ```php $url = Q_Uri::url("MyPlugin/claim " . json_encode(array('id' => $id))); ``` --- ## 7. Output — Slots, Layouts, Views ```php // Page content handler function MyPlugin_page_response_content() { $stream = Streams::fetchOne(null, $publisherId, $streamName, true); return Q::view('MyPlugin/content/page.php', compact('stream')); } // PHP tool renderer function MyPlugin_thing_tool($options) { Q_Response::setToolOptions($options); // exports options to JS constructor $stream = Streams::fetchOne(...); return Q::view('MyPlugin/tool/thing.php', compact('stream', 'options')); } // Render tools in PHP view echo Q::tool("MyPlugin/thing", array( 'publisherId' => $publisherId, 'streamName' => $streamName )); // Multiple tools same type — id required echo Q::tool("MyPlugin/thing", $options, $uniqueId); // Multiple tool types on one element echo Q::tool(array( 'Q/tabs' => $tabOptions, 'MyPlugin/list' => $listOptions ), 'uniqueId'); // Pass data to client JS Q_Response::setScriptData("MyPlugin.thing", $thing->exportArray()); Q_Response::setScriptData("MyPlugin.things", Db::exportArray($things)); // Preload a stream (client gets it cached on page load) $stream->addPreloaded($asUserId); // Add scripts/stylesheets to slot Q_Response::addScript('js/foo.js'); Q_Response::addStylesheet('css/foo.css'); // Export view as client-side Handlebars template Q_Response::addTemplate("MyPlugin/thing", "handlebars"); // Redirect (before Q/response fires) Q_Response::redirect('MyPlugin/welcome'); // Errors for form validation (client renders inline) Q_Response::addError($exception); ``` Q_Html helpers (URI strings or URLs): ```php Q_Html::text($str); // escape for HTML Q_Html::a($uri, $attrs); // link Q_Html::form($action, 'post', $attrs); Q_Html::img($src, $alt, $attrs); Q_Html::tag($tagname, $attrs); ``` --- ## 8. Q.Tool.define — Complete Pattern ```javascript (function ($, window, undefined) { Q.Tool.define("MyPlugin/name", // ── 1. Constructor ──────────────────────────────────────────────────────── function (options) { var tool = this; var state = tool.state; // tool.text.* — i18n strings from en.json (loaded via text: [...] in registration) tool.rendering(['fieldA', 'fieldB'], function (changed, previous) { // DO NOT read from DOM here. DO NOT modify tool.state here. if (tool.$someEl) tool.$someEl.textContent = state.fieldA; }); tool.refresh(); }, // ── 2. Default options ──────────────────────────────────────────────────── { publisherId: null, streamName: null, fieldA: '', editable: false, onRefresh: new Q.Event() }, // ── 3. Methods ──────────────────────────────────────────────────────────── { refresh: function () { var tool = this; var state = tool.state; Q.Template.render('MyPlugin/name', { text: tool.text, fieldA: state.fieldA }, function (err, html) { if (err) return console.warn(err); tool.element.innerHTML = html; Q.activate(tool.element, function () { tool.$someEl = tool.element.querySelector('.MyPlugin_some'); tool._bindEvents(); state.onRefresh.handle.call(tool); }); }); }, _bindEvents: function () { var tool = this; $(tool.element).on('click.MyPlugin_name', '.MyPlugin_btn', function () { tool.state.fieldA = 'new value'; tool.stateChanged('fieldA'); }); }, _addChild: function (publisherId, streamName) { var tool = this; var id = streamName.split('/').pop(); var el = Q.Tool.setUpElement( 'div', 'MyPlugin/child', { publisherId: publisherId, streamName: streamName }, id, tool.prefix // ALWAYS pass parent prefix ); tool.element.querySelector('.MyPlugin_list').appendChild(el); Q.activate(el); }, Q: { beforeRemove: function () { $(this.element).off('.MyPlugin_name'); }, onRetain: function (newOptions) { // compare newOptions to this.state and update display }, onInit: function () { // called after all child tools are initialized } } }); Q.Template.set('MyPlugin/name', '
{{text.Description}}
' ); // Render (callback style) Q.Template.render('MyPlugin/thing', { text: tool.text, title: state.title }, function (err, html) { Q.activate(Q.replace(someElement, html)); }); // Handlebars helpers available in templates: // {{&tool "Streams/chat" "id" max=10 }} — render a tool // {{call "stream.getAttribute" "foo"}} — call a method // {{& src}} — unescaped HTML; {{src}} — escaped ``` Preloading via PHP (avoids extra round-trip): ```php Q_Response::addTemplate("MyPlugin/thing", "handlebars"); // Makes views/MyPlugin/thing.php available as a client-side template ``` --- ## 10. Tool Registration (MyPlugin/web/MyPlugin.js) ```javascript Q.Tool.define({ "MyPlugin/claim": { js: "js/tools/claim.js", css: "css/tools/claim.css", text: ["MyPlugin/content"] // loads text/MyPlugin/content/en.json }, "MyPlugin/list": { js: "js/tools/list.js", css: "css/tools/list.css", text: ["MyPlugin/content", "Streams/content"] } }); ``` --- ## 11. Database — Db Layer ```php $db = Db::connect('MyPlugin'); // SELECT $rows = $db->select('*', 'myplugin_thing') ->where(array( 'publisherId' => $publisherId, // = operator prepended automatically 'type' => 'MyPlugin/claim', 'status !=' => 'deleted', // operator in key 'score >' => 5 )) ->orderBy('score', false) // false = DESC ->limit(20, 0) // limit, offset ->fetchAll(PDO::FETCH_ASSOC); // IN clause $rows = $db->select('*', 'myplugin_thing') ->where(array('type' => array('type1', 'type2', 'type3'))) ->fetchAll(); // Db_Range for inequalities $range = new Db_Range($min, true, false, $max); // $min <= x < $max // Db_Expression — raw SQL, bypasses sanitization ->where(array('created >' => new Db_Expression("NOW() - INTERVAL 1 DAY"))) // INSERT $db->insert('myplugin_thing', array( 'id' => $id, 'publisherId' => $publisherId, 'content' => $content ))->execute(); // UPDATE $db->update('myplugin_thing') ->set(array('score' => 10, 'status' => 'active')) ->where(array('id' => $id)) ->execute(); // DELETE $db->delete('myplugin_thing')->where(array('id' => $id))->execute(); // Batch insert with upsert MyPlugin_Thing::insertManyAndExecute($rows, array( 'onDuplicateKeyUpdate' => array('score' => 1) )); ``` Fetching via query caches by SQL — same query twice in one request only hits DB once. --- ## 12. Models (ORM — Db_Row) Edit `classes/MyPlugin/Thing.php`, **never** `classes/Base/`. ```php // Insert $thing = new MyPlugin_Thing(); $thing->id = MyPlugin_Thing::db()->uniqueId(MyPlugin_Thing::table(), 'id'); $thing->publisherId = $publisherId; $thing->content = $content; $thing->save(); // INSERT // Fetch one $thing = new MyPlugin_Thing(); $thing->id = $id; if (!$thing->retrieve()) { throw new Q_Exception_MissingRow(array('table' => 'thing', 'criteria' => $id)); } // Upsert (ON DUPLICATE KEY UPDATE) $thing = new MyPlugin_Thing(); $thing->id = $id; $thing->save(true); // Fetch many $things = MyPlugin_Thing::select('*') ->where(array('publisherId' => $publisherId)) ->orderBy('insertedTime', false) ->limit(20, 0) ->fetchDbRows(); // returns array of MyPlugin_Thing objects // Update (after retrieve) $thing->score = 10; $thing->save(); // UPDATE // Delete $thing = new MyPlugin_Thing(); $thing->id = $id; $thing->remove(); // In-memory metadata (not persisted) $thing->set('key', $value); $val = $thing->get('key', $default); $thing->clear('key'); // Export to client Q_Response::setScriptData("MyPlugin.thing", $thing->exportArray()); Q_Response::setScriptData("MyPlugin.things", Db::exportArray($things)); // Hooks function beforeSave($modifiedFields) { // validate or transform before save return parent::beforeSave($modifiedFields); } function afterSaveExecute($result, $query, $modifiedFields, $where) { // fires after INSERT/UPDATE — use for cascades, notifications return $result; } // Relations between models (in setUp()) $this->hasOne('author', array('a' => 'MyPlugin_Thing', 'u' => 'Users_User'), array('a.byUserId' => 'u.id')); $this->hasMany('tags', array('a' => 'MyPlugin_Thing', 't' => 'MyPlugin_Tag'), array('a.id' => 't.thingId')); // Usage: $thing->get_tags('*'); ``` Class autoloading: `MyPlugin_Thing_Sub` → `classes/MyPlugin/Thing/Sub.php`. --- ## 13. Streams — PHP ```php // Fetch $stream = Streams::fetchOne($asUserId, $publisherId, $streamName); // null if missing $stream = Streams::fetchOne($asUserId, $publisherId, $streamName, true); // throws if missing $stream = Streams::fetchOneOrCreate($asUserId, $publisherId, $streamName, array( 'fields' => array('type' => 'MyPlugin/thing', 'title' => 'Title'), 'skipAccess' => true ), $results); if ($results['created']) { /* first time */ } // Create $stream = Streams::create($asUserId, $publisherId, array( 'type' => 'MyPlugin/thing', 'title' => 'Title', 'content' => 'Description', 'attributes' => Q::json_encode(array('foo' => 'bar')) )); // Attributes $val = $stream->getAttribute('foo', $default); $stream->setAttribute('foo', 'bar'); $stream->setAttribute(array('foo' => 'bar', 'baz' => 1)); // batch $stream->changed($asUserId); // save + notify + fire hooks // Access checks $stream->testReadLevel('content'); // 'none','see','content','participants','messages' $stream->testWriteLevel('post'); // 'none','join','vote','post','relate','edit','close' $stream->testAdminLevel('manage'); // 'none','tell','invite','manage','own' $stream->testPermission('myPerm'); // Post message $stream->post($asUserId, array( 'type' => 'MyPlugin/thing/updated', 'content' => 'Human-readable description', 'instructions' => Q::json_encode(array('key' => 'value')) ), true); // true = skip access check // Relate (fromStream = child, toStream = parent/category) Streams::relate( $asUserId, $toPublisherId, $toStreamName, // parent 'MyPlugin/relationType', $fromPublisherId, $fromStreamName, // child array('weight' => 1) ); Streams::unrelate($asUserId, $toPublisherId, $toStreamName, 'MyPlugin/relationType', $fromPublisherId, $fromStreamName); // Fetch related list($relations, $relatedStreams, $stream) = Streams::related( $asUserId, $publisherId, $streamName, 'MyPlugin/relationType', true, // isCategory=true → fetch "from" streams (children) array('limit' => 50) ); // Cheap relations query (no stream fetch) $rows = Streams_RelatedTo::select('*')->where(array( 'toPublisherId' => $publisherId, 'toStreamName' => $streamName, 'type' => 'MyPlugin/relationType' ))->fetchDbRows(); // each row: ->fromPublisherId, ->fromStreamName, ->type, ->weight // Join / subscribe $stream->join($asUserId); // user becomes participant $stream->subscribe($asUserId); // user gets offline notifications // Preload for client $stream->addPreloaded($asUserId); ``` **Stream naming:** `name` always starts with type prefix: `type: "MyPlugin/claim"` → `name: "MyPlugin/claim/{id}"` --- ## 14. Streams — JavaScript ```javascript // Fetch one stream Q.Streams.get(publisherId, streamName, function (err, stream) { if (err) return console.warn(err); var val = stream.getAttribute('foo'); var title = stream.fields.title; // always .fields.name NOT .streamName // fields: publisherId, name, type, title, content, attributes, messageCount, ... }); // Retain for tool lifetime Q.Streams.retainWith(tool).get(publisherId, streamName, function () { var stream = this; tool.state.stream = stream; }); // Fetch related streams // CRITICAL: callback is function(errorMessage) — NOT function(err, result) Q.Streams.related( publisherId, streamName, 'MyPlugin/relationType', true, // isCategory: true → children are "from" { limit: 50 }, function (errorMessage) { if (errorMessage) return console.warn(errorMessage); var result = this; Q.each(result.relations, function () { var child = this.from; // isCategory=true → use .from }); // result.relatedStreams — keyed "publisherId\tstreamName" // result.stream — the parent stream } ); // Live message listener (tied to tool lifetime) stream.onMessage('MyPlugin/thing/updated').set(function (msg) { var inst = JSON.parse(msg.instructions || '{}'); // msg: ordinal, type, content, instructions, byUserId, sentTime }, tool); // Listen for any message on a stream type Q.Streams.onMessage('MyPlugin/thing', 'MyPlugin/thing/updated').set(handler, key); // Stream field access stream.fields.name // stream name (NOT .streamName) stream.fields.publisherId stream.fields.type stream.fields.title stream.fields.content stream.getAttribute('key') // from JSON attributes stream.setAttribute('key', value); // pending; call stream.save() to persist stream.save(callback); // issues PUT to Streams/stream // Field/attribute change events Q.Streams.Stream.onUpdated(pubId, streamName, 'attrName').set(function (attrs) { ... }); Q.Streams.Stream.onFieldChanged(pubId, streamName, 'title').set(function (fields) { ... }); // Refresh all retained streams (call after coming back online) Q.Streams.refresh(callback); // Define a client-side stream type constructor Q.Streams.define("MyPlugin/thing", function (fields) { this.fields.extra = fields.extra; this.onMessage('MyPlugin/thing/updated').set(function (stream, msg) { // update local stream state from message }, 'MyPlugin/thing'); // DO NOT add events as properties here — stream objects are replaced on refresh }); ``` --- ## 15. Streams — Access Control Access levels are numeric; use string constants when testing: | Level | Read | Write | Admin | |-------|------|-------|-------| | 0 | none | none | none | | 10 | see | join | publish | | 13 | — | vote | — | | 15 | teaser | suggest | — | | 18 | — | contribute | — | | 20 | relations | post | invite | | 23 | content | relate | — | | 25 | fields | — | — | | 30 | participants | edit | manage | | 35 | messages | — | — | | 40 | receipts | close | own | **Common test strings:** `testReadLevel('messages')` = 35, `testWriteLevel('post')` = 20, `testWriteLevel('relate')` = 23, `testWriteLevel('edit')` = 30, `testAdminLevel('manage')` = 30, `testAdminLevel('own')` = 40. Public access: `readLevel`, `writeLevel`, `adminLevel` fields on `Streams_Stream` row. Granular: `Streams_Access` rows with `ofContactLabel` (aggregated by max) or `ofUserId` (overrides labels). Templates: stream with `name = "$type/"` — fields/access copied to new streams of that type. Mutable: stream with `name = "$type/*"` — access computed at fetch time, not copied. Stream type config in `plugin.json`: ```json { "Streams": { "types": { "MyPlugin/thing": { "create": ["content", "title"], "edit": ["content", "title"], "defaults": { "readLevel": 40, "icon": "MyPlugin/thing" }, "messages": { "MyPlugin/thing/updated": { "edit": true, "subject": "Thing was updated" } } } } } } ``` --- ## 16. Users Plugin ```php // PHP $user = Users::loggedInUser(true); // throws if not logged in $user = Users::loggedInUser(false); // returns null $communityId = Users::communityId(); // community publisher ID $communityId = Users::communityId('MyPlugin'); // app-specific community // Vote Users_Vote::vote( 'MyPlugin/thing', // forType array($streamName), // forId array array(1), // values array(1), // weights $user ); // Users_Total auto-updates via beforeSave — never calculate totals manually // Read total $total = new Users_Total(); $total->forType = 'MyPlugin/thing'; $total->forId = $streamName; if ($total->retrieve()) { $strength = (float)$total->weightTotal; $count = (int)$total->voteCount; $value = (float)$total->value; // weighted average } ``` ```javascript // JS if (Q.Users.loggedInUser) { ... } var userId = Q.Users.loggedInUserId(); if (Q.Users.roles["MyPlugin/admins"]) { ... } // check role Q.Users.login({ onSuccess: function(user) { ... } }); Q.Users.logout(); // Events Q.Users.onLogin.set(handler, key); Q.Users.onLogout.set(handler, key); // Vote Q.plugins.Users.vote('MyPlugin/thing', forId, value, function(err, result) { ... }); ``` --- ## 17. Assets / Credits ```php // Unified payment — never throws; returns array with 'success' key $result = Assets::pay( $communityId, $userId, 1, // amount (credits by default) 'MyPlugin/reason', // reason — required, appears in ledger array( 'currency' => 'credits', 'toPublisherId' => $publisherId, 'toStreamName' => $streamName ) ); if (!$result['success']) { Q_Response::setSlot('result', array( 'success' => false, 'needCredits' => Q::ifset($result, 'details', 'needCredits', 1), 'intentToken' => Q::ifset($result, 'details', 'intentToken', null) )); return; } // Transfer credits between users Assets::transfer($communityId, $amount, 'MyPlugin/reason', $toUserId, $fromUserId, array('forcePayment' => true)); // Direct spend/grant Assets_Credits::spend($communityId, $amount, $reason, $userId, $options); Assets_Credits::grant($communityId, $amount, 'ReasonKey', $userId); // Check balance $balance = Assets_Credits::amount($communityId, $userId); // Convert currencies $credits = Assets_Credits::convert(2.50, 'USD', 'credits'); $usd = Assets_Credits::convert(250, 'credits', 'USD'); // Award a badge Assets_Earned::badge($userId, 'first_post', array( 'publisherId' => $stream->publisherId, 'streamName' => $stream->name, 'earnedTime' => time(), 'duplicate' => false // false = prevent awarding twice )); // Leaderboard / totals $leaders = Assets_Badge::badgesAndTotals( strtotime('-30 days'), time(), array('communityId' => $communityId) ); // Returns: ['userId' => ['badges' => [...], 'total' => 37], ...] // Track referral Users_Referred::handleReferral($asUserId, $publisherId, $streamType, $invitingUserId); // Payout to user Assets_Payouts::send($user, array( 'amountCredits' => 500, 'description' => 'Creator revenue – March' )); ``` --- ## 18. Config ```json // MyPlugin/config/plugin.json { "Q": { "plugins": ["MyPlugin"] }, "MyPlugin": { "credits": { "voteArgument": 1 }, "limits": { "maxArgs": 5 } } } ``` ```php // Read (with default) $val = Q_Config::get('MyPlugin', 'credits', 'voteArgument', 1); // Read (throws if missing) $val = Q_Config::expect('MyPlugin', 'llm', 'apiKey'); // Cross-plugin $apiKey = Q_Config::get('AI', 'anthropic', 'key', null); // Set at runtime Q_Config::set('MyPlugin', 'foo', $value); ``` DB connections in `APP_DIR/local/app.json` (machine-specific, not in plugin.json): ```json { "Db": { "connections": { "*": { "dsn": "mysql:host=localhost;dbname=mydb", "username": "u", "password": "p" }, "MyPlugin": { "prefix": "myplugin_" } } } } ``` Config merge rules: scalars replace, objects merge recursively, arrays append, `{"replace": [...]}` replaces an array. --- ## 19. Q.Request Quick Reference ```php // Q_Request is for request metadata — NOT for reading field values. // To read field values, use $_GET / $_POST / $_REQUEST directly, // or use Q_Request::requireFields(fields, true) which throws if the field is absent. Q_Request::requireFields('field', true); // throws Q_Exception_RequiredField if missing // checks $_REQUEST (GET + POST) // For optional fields with defaults, use superglobals: $val = Q::ifset($_POST, 'field', $default); $val = Q::ifset($_GET, 'field', $default); $val = Q::ifset($_REQUEST, 'field', $default); // Request metadata methods: Q_Request::method(); // 'GET', 'POST', 'PUT', 'DELETE' Q_Request::isAjax(); // true for Q.req calls Q_Request::slotNames(); // slots the client wants filled Q_Request::slotName('result'); // whether a specific slot was requested Q_Request::special('onSuccess'); // value of special Q.* fields Q_Request::baseUrl(); // base URL of app Q_Request::url(); // full requested URL Q_Request::isMobile(); Q_Request::isTablet(); Q_Request::isTouchscreen(); Q_Request::formFactor(); // 'mobile', 'tablet', 'desktop' Q_Request::platform(); // 'ios', 'android', 'mac', 'windows' Q_Request::osVersion(); Q_Request::isIE($min, $max); ``` --- ## 20. JS Patterns (Q.pipe, Q.getter, Q.latest, Q.debounce) ```javascript // Q.pipe — run handler when multiple async results arrive var p = new Q.Pipe(['user', 'stream'], function (args, subjects) { // args.user, args.stream — arguments passed to fill() // subjects.user, subjects.stream — "this" context }); Q.Users.get(userId, p.fill('user')); Q.Streams.get(pubId, name, p.fill('stream')); // Chaining pipes (second param true = don't re-run this step) var p2 = new Q.Pipe(['users', 'stream'], function (args, subjects) { doSomething(args.users, args.stream); db.query(p2.fill('extra', true)); // true = don't re-trigger this step }).set(['users', 'extra'], function (args) { // runs when both 'users' and 'extra' are filled }); // Q.latest — ignore stale async responses var ordinal = Q.latest('MyPlugin/search'); doAsyncRequest(function (err, results) { if (!Q.latest('MyPlugin/search', ordinal)) return; // stale renderResults(results); }); // Q.debounce — fire after N ms of silence $(input).on('keyup', Q.debounce(function() { search(); }, 300)); // Q.throttle — fire at most once per N ms $(window).on('scroll', Q.throttle(function() { update(); }, 100)); // Q.getter — caching, throttling, waitlisting wrapper MyPlugin.getThings = Q.getter(function (id, callback) { Q.req('MyPlugin/things', ['result'], function(err, res) { callback(err, res && res.slots.result); }, { fields: { id: id } }); }, { cache: new Q.Cache.document("MyPlugin.getThings"), throttle: "MyPlugin.getThings" }); // Q.once — call at most once getVariousResults(Q.once(callback)); // Q.normalize — standard identifier chars var id = Q.normalize(name); ``` --- ## 21. Sockets ```javascript // Connect to a namespace Q.Socket.connect("Streams", Q.nodeUrl(), function (socket) { // socket is now connected socket.onEvent('MyPlugin/event').set(function(data) { ... }, key); }); // Wait for connection Q.Socket.onConnect("Streams", url).set(function() { ... }, key); // Disconnect / reconnect socket.disconnect(); Q.Socket.disconnectAll(); Q.Socket.reconnectAll(); ``` Node.js server-side (APP_DIR/scripts/MyPlugin/MyPlugin.js): ```javascript require('../Q.inc')(function(Q) { Q.plugins.Users.listen(); Q.plugins.Streams.listen(); // Custom handler Q.on('MyPlugin/doThing', function(params, socket) { var publisherId = params.publisherId; // do async work, then emit Q.Streams.Stream.emit(publisherId, params.streamName, 'MyPlugin/thing/updated', { instructions: JSON.stringify({ result: 'done' }) } ); }); }); ``` PHP → Node.js: ```php Q_Utils::sendToNode(array( 'Q/method' => 'MyPlugin/doThing', 'publisherId' => $publisherId, 'streamName' => $streamName, 'data' => $data )); ``` --- ## 22. i18n Text ```json // MyPlugin/text/MyPlugin/content/en.json { "Title": "My Plugin", "Vote": { "Upvote": "Upvote (1 credit)", "Already": "Already voted" }, "errors": { "NotFound": "Not found.", "NotEnoughCredits": "Not enough credits." } } ``` In JS tool: `tool.text.Vote.Upvote` In Handlebars template: `{{text.Vote.Upvote}}` Register in tool definition: `text: ["MyPlugin/content"]` --- ## 23. Streams Stream Naming & Ontology Stream name always starts with type prefix: ``` type: "MyPlugin/claim" → name: "MyPlugin/claim/{id}" type: "MyPlugin/argument" → name: "MyPlugin/argument/{id}" type: "Assets/credits" → name: "Assets/credits/{userId}" ``` External publisher IDs: reverse-domain — `com.twitter`, `com.facebook`. Relations: **fromStream = child, toStream = parent/category**. `isCategory=true` = fetching children (from) of this parent (to). Built-in stream ontology (`Streams/`): text, file, image, audio, video, pdf, live, chat, category, task, tasks, interest, topic, question, answer, search, space. (`Calendars/`): calendar, event. (`Assets/`): product, service, holding. --- ## 24. Update Scripts Named `scripts/MyPlugin/{version}-{connection}.mysql` (or `.mysql.php`): ```sql -- scripts/MyPlugin/1.1-MyPlugin.mysql ALTER TABLE `{{prefix}}thing` ADD `score` INT NOT NULL DEFAULT 0; ``` PHP update scripts are run as CLI. Increment version in `config/plugin.json` and run installer. --- ## 25. Common Mistakes | Wrong | Right | |-------|-------| | `handlers/MyPlugin/argument/vote/post.php` | `handlers/MyPlugin/vote/post.php` — no slashes in action name | | `function MyPlugin_vote_POST()` | `function MyPlugin_vote_post()` — lowercase | | `Q.Streams.related(..., function(err, related){})` | `function(errorMessage){ var result = this; }` | | `this.to` when `isCategory=true` | `this.from` — use `.from` for children | | `stream.fields.streamName` | `stream.fields.name` — always `.name` | | `Q.Tool.setUpElement(..., id, null)` | Always pass `tool.prefix` as last arg | | `Q.Tool.setUpElement` without `Q.activate` | Always follow with `Q.activate(el)` | | Raw innerHTML replace with child tools inside | `Q.Tool.clear(container)` first | | jQuery events without namespace | `.on('click.MyPlugin_name', ...)` always namespace | | No `.off('.MyPlugin_name')` in beforeRemove | Always clean up namespaced events | | `Q_Response::setSlot` in response slot handler | `return $value` — return fills slot | | `$stream->save()` to persist attributes | `$stream->setAttribute(...); $stream->changed($userId)` | | Editing `classes/Base/...` files | Only edit `classes/MyPlugin/...` | | Calculating totals manually | Use `Users_Total` / `Assets_Badge::badgesAndTotals()` | | Adding events as properties in `Q.Streams.define` | Stream objects replaced on refresh — don't do `this.onFoo = new Q.Event()` | | `new Q.Pipe(...)` without calling `pipe.run()` after adding handlers | Call `p.run()` if objects may already be filled | | Off-session Stripe charge without saved card | Only works if first checkout used `setup_future_usage = 'off_session'` | | Firing `Q::event` for side effects inside `Q/response` | Use `Q/$verb` (Q/post, Q/put) for state changes; `Q/response` is output only | --- ## 26. Authoritative DB Schema — Key Tables Use exact column names below when writing ORM code or raw queries. ### streams_stream ```sql publisherId varbinary(31) PK name varbinary(255) PK -- always .fields.name in JS, NOT .streamName insertedTime timestamp updatedTime timestamp type varchar(63) title varchar(255) icon varbinary(255) DEFAULT 'default' content varbinary(4095) attributes varchar(1023) -- JSON readLevel int DEFAULT 40 writeLevel int DEFAULT 10 adminLevel int DEFAULT 20 permissions varchar(255) -- JSON array of strings inheritAccess varbinary(255) -- JSON [[publisherId,streamName],...] messageCount int DEFAULT 0 participatingCount int DEFAULT 0 invitedCount int DEFAULT 0 arrivedCount int DEFAULT 0 leftCount int DEFAULT 0 closedTime timestamp NULL fork varbinary(4095) -- JSON {publisherId,streamName,ordinal,time} ``` ### streams_message ```sql publisherId varbinary(31) PK streamName varbinary(255) PK ordinal int unsigned PK -- 1-based, auto-incremented by Streams insertedTime timestamp sentTime timestamp NULL byUserId varbinary(31) byClientId varbinary(31) type varbinary(255) -- e.g. "MyPlugin/thing/updated" content varchar(4095) -- human-readable instructions varchar(8191) -- machine-readable JSON weight decimal(14,4) DEFAULT 1.0 ``` ### streams_participant ```sql publisherId varbinary(31) PK streamName varbinary(255) PK userId varbinary(31) PK streamType varchar(63) insertedTime timestamp updatedTime timestamp state enum('invited','participating','left') subscribed enum('yes','no') DEFAULT 'no' posted enum('yes','no') DEFAULT 'no' extra varchar(1023) DEFAULT '{}' -- JSON ``` ### streams_access ```sql publisherId varbinary(31) PK streamName varbinary(255) PK ofUserId varbinary(31) PK DEFAULT '' -- specific user override ofContactLabel varbinary(255) PK DEFAULT '' -- label-based access ofParticipantRole varchar(63) PK DEFAULT '' grantedByUserId varbinary(31) readLevel int DEFAULT -1 -- -1 = don't override writeLevel int DEFAULT -1 adminLevel int DEFAULT -1 permissions varchar(255) -- JSON array ``` Label rows aggregate (max); userId rows override labels. ### streams_related_to (parent/category side) ```sql toPublisherId varbinary(31) PK -- parent/category publisher toStreamName varbinary(255) PK -- parent/category name type varbinary(255) PK DEFAULT '' fromPublisherId varbinary(31) PK -- child publisher fromStreamName varbinary(255) PK -- child name weight decimal(14,4) DEFAULT 1.0 insertedTime timestamp extra varchar(1023) DEFAULT '{}' -- JSON edge metadata ``` ### streams_related_from (child/member side) ```sql fromPublisherId varbinary(31) PK fromStreamName varbinary(255) PK type varbinary(255) PK DEFAULT '' toPublisherId varbinary(31) PK toStreamName varbinary(255) PK insertedTime timestamp ``` ### streams_subscription ```sql publisherId varbinary(31) PK streamName varbinary(255) PK ofUserId varbinary(31) PK insertedTime timestamp updatedTime timestamp untilTime timestamp NULL filter varchar(255) -- JSON {"type":[...], "notifications": N} duration int DEFAULT 0 ``` ### streams_invite ```sql token varbinary(255) PK userId varbinary(31) -- invitee publisherId varbinary(31) streamName varbinary(255) invitingUserId varbinary(31) displayName varchar(255) appUrl varbinary(255) readLevel int NULL writeLevel int NULL adminLevel int NULL permissions varchar(255) state enum('pending','accepted','declined','arrived','forwarded','expired') insertedTime timestamp expireTime timestamp NULL extra varchar(1023) ``` ### streams_category (denormalized fast index) ```sql publisherId varbinary(31) PK streamName varbinary(255) PK insertedTime timestamp updatedTime timestamp relatedTo text -- JSON snapshot of related items ``` ### users_user ```sql id varbinary(31) PK insertedTime timestamp updatedTime timestamp sessionId varbinary(255) emailAddress varbinary(255) mobileNumber varbinary(255) emailAddressPending varbinary(255) mobileNumberPending varbinary(255) username varchar(63) icon varbinary(255) signedUpWith varchar(31) -- 'none','email','mobile','facebook',... xids varchar(1023) -- JSON {platform: [xid,...]} preferredLanguage varchar(3) DEFAULT 'en' ``` ### users_vote ```sql userId varbinary(31) PK forType varbinary(31) PK -- e.g. 'MyPlugin/thing' forId varbinary(255) PK -- e.g. streamName value decimal(14,4) DEFAULT 0 weight decimal(14,4) DEFAULT 1 updatedTime timestamp extra varchar(1023) -- JSON ``` ### users_total (auto-maintained, never write directly) ```sql forType varbinary(31) PK forId varbinary(255) PK voteCount bigint DEFAULT 0 weightTotal decimal(14,4) DEFAULT 0 -- sum of weights value decimal(14,4) DEFAULT 0 -- weighted average of values updatedTime timestamp ``` ### users_contact ```sql userId varbinary(31) PK label varchar(63) PK contactUserId varbinary(31) PK nickname varchar(255) DEFAULT '' insertedTime timestamp ``` ### users_label ```sql userId varbinary(31) PK label varchar(63) PK -- e.g. 'MyPlugin/admins' icon varbinary(255) DEFAULT 'default' title varchar(255) insertedTime timestamp updatedTime timestamp ``` ### users_referred ```sql userId varbinary(31) PK toCommunityId varbinary(31) PK referredByUserId varbinary(31) PK -- NOT byUserId insertedTime timestamp updatedTime timestamp points decimal(4,2) qualifiedTime timestamp NULL extra varchar(1023) DEFAULT '{}' ``` ### assets_credits (journal — every credit transfer) ```sql id varbinary(31) PK fromUserId varbinary(31) toUserId varbinary(31) fromPublisherId varbinary(31) fromStreamName varbinary(255) toPublisherId varbinary(31) toStreamName varbinary(255) reason varchar(255) -- key in Q.Text communityId varbinary(31) NOT NULL amount decimal(10,4) NOT NULL attributes varchar(1023) -- JSON insertedTime timestamp updatedTime timestamp ``` ### assets_charge ```sql userId varbinary(31) PK id varbinary(255) PK publisherId varbinary(31) DEFAULT '' streamName varbinary(255) DEFAULT '' amount decimal(10,2) currency char(3) credits bigint paymentProvider varchar(32) providerCustomerId varchar(255) autoCharge tinyint(1) DEFAULT 0 communityId varbinary(31) app varchar(64) reason varchar(64) description varchar(255) attributes varchar(1023) insertedTime timestamp updatedTime timestamp status enum('pending','completed','failed','reversed') DEFAULT 'pending' ``` ### assets_customer ```sql userId varbinary(31) PK payments enum('stripe','authnet') PK DEFAULT 'stripe' customerId varbinary(255) -- e.g. cus_XXXX hash varchar(32) PK insertedTime timestamp updatedTime timestamp ``` ### assets_badge ```sql appId varbinary(31) PK communityId varbinary(31) name varchar(63) PK icon varbinary(255) title varchar(255) NOT NULL description text points smallint DEFAULT 0 ``` ### assets_earned ```sql appId varbinary(31) -- no PK: multiple earns allowed communityId varbinary(31) earnedTime timestamp DEFAULT CURRENT_TIMESTAMP userId varchar(31) badgeName varchar(255) publisherId varbinary(31) streamName varbinary(255) -- KEY byTime (appId, communityId, earnedTime) -- KEY byUser (appId, communityId, userId) ``` ### assets_leader ```sql communityId varbinary(31) PK day date PK userId varchar(31) PK points smallint DEFAULT 0 ``` ### streams_task (for AI/worker tasks) ```sql publisherId varbinary(31) PK streamName varbinary(255) PK instructions longblob -- binary-encoded task instructions errors longblob -- accumulated errors ``` ### streams_message_total (fast count cache) ```sql publisherId varbinary(31) PK streamName varbinary(255) PK messageType varbinary(255) PK messageCount bigint DEFAULT 0 ``` ### streams_related_to_total / streams_related_from_total ```sql -- _to version: toPublisherId varbinary(31) PK toStreamName varbinary(255) PK relationType varbinary(255) PK fromStreamType varbinary(255) PK relationCount bigint DEFAULT 0 -- _from version: fromPublisherId/fromStreamName/relationType/toStreamType ``` --- ## 27. Schema-Derived Corrections to Common Patterns ```php // streams_access: -1 means "don't override this level" // To ban a specific user from reading but leave write alone: $access = new Streams_Access(); $access->publisherId = $publisherId; $access->streamName = $streamName; $access->ofUserId = $bannedUserId; $access->readLevel = 0; // none $access->writeLevel = -1; // unchanged $access->adminLevel = -1; // unchanged $access->save(true); // Correct field: referredByUserId (not byUserId or invitingUserId) $r = new Users_Referred(); $r->userId = $userId; $r->toCommunityId = $communityId; $r->referredByUserId = $invitingUserId; // streams_participant state values // 'invited' | 'participating' | 'left' (not 'joined') $p = Streams_Participant::select('*') ->where(array( 'publisherId' => $publisherId, 'streamName' => $streamName, 'state' => 'participating' ))->fetchDbRows(); // streams_message: ordinal is unsigned int, auto-set by Streams layer // Never set ordinal manually — let Streams::post() handle it // instructions is varchar(8191) — keep JSON under ~8000 chars // content is varchar(4095) — human-readable summary only // assets_credits: 'amount' can be negative (debit) or positive (credit) // Always go through Assets::pay() or Assets_Credits::spend()/grant() // Never write directly to assets_credits — use the API // users_total: NEVER UPDATE MANUALLY // Users_Vote::vote() calls beforeSave() which auto-updates users_total ``` # Users Plugin — LLM Coding Primer Supplement to the Q Framework primer. Covers identity, auth, contacts, roles, devices, intents, referrals, and Web3. Read before writing Users-related code. --- ## 1. User Identity ```php $user = Users::loggedInUser(true); // throws if not logged in $user = Users::loggedInUser(false); // null if not logged in $userId = Users::loggedInUserId(); // shortcut — returns string or null $user = Users::fetch($userId, true); // throws if missing // Community vs person Users::isCommunityId($userId); // true if starts with uppercase $communityId = Users::communityId(); // main community publisher // Display name $name = Users::displayName($userId, array('short' => true)); // Export safe fields to client $exported = $user->exportArray(); // Returns: id, username, signedUpWith, icon, url, xids, preferredLanguage, sessionCount ``` ```javascript // JS var user = Q.Users.loggedInUser; // object or null var userId = Q.Users.loggedInUserId(); // string or "" Q.Users.isCommunityId(userId); // Events Q.Users.onLogin.set(function (user) { ... }, 'MyPlugin'); Q.Users.onLogout.set(function () { ... }, 'MyPlugin'); ``` --- ## 2. Registration ```php $user = Users::register( $username, // can be '' for auto-generated $identifier, // email, mobile number, or array $icon, // array of icon URLs, or callable $options // see below ); // Identifier can be: // 1. Email string: "alice@example.com" // 2. Phone string: "+15551234567" // 3. Platform array: array('app' => array('platform' => 'facebook')) // 4. Device array: array('device' => array('deviceId' => ..., 'platform' => 'ios', ...)) // Options: // 'skipIdentifier' => true — register without email/mobile // 'leaveDefaultIcon' => true — don't search for gravatar/social icon // Events fired: Users/register {before}, {after} // After: Users_User row inserted, session established, personal streams created ``` --- ## 3. Authentication ```php // Platform auth (Facebook, Twitter, Web3, etc.) $user = Users::authenticate( $platform, // 'facebook', 'web3', etc. Must be in Users/apps/platforms config $appId, // null = Q::app() $authenticated, // OUT: true, 'connected', 'adopted', or false $import // field names to import from platform profile ); // Native login (identifier + passphrase) $user = Users::login($identifier, $passphrase, $isHashed); // $isHashed = true if client already hashed with Users.hashing() // Set logged-in user manually (after custom auth) Users::setLoggedInUser($user); // Logout Users::logout(); ``` ```javascript // JS login dialog Q.Users.login({ identifierType: 'email,mobile', using: 'native,web3,facebook', onSuccess: function (user) { /* user.id, user.displayName */ } }); Q.Users.logout({ onSuccess: function () { ... } }); // Passphrase hashing (client-side, before sending to server) var hashed = Users.hashing(passphrase, identifier); ``` --- ## 4. External Platform Identities ```php // Fetch the ExternalFrom record for a platform user $ef = Users_ExternalFrom::authenticate('facebook', $appId); // Returns Users_ExternalFrom_Facebook (or null if not authenticated) // Look up Qbix userId from platform identity $userId = Users_ExternalFrom::fetchUserId('facebook', $appId, $xid); // Platform-specific extras $ef->getExtra('accessToken'); $ef->setExtra('refreshToken', $token); $ef->save(); // User's xids JSON field $xid = $user->getXid('facebook_' . $appId); // returns xid string or null $user->setXid('facebook_' . $appId, $xid); $user->clearXid('facebook_' . $appId); $allXids = $user->getAllXids(); // returns decoded JSON object // Supported adapters: // Users_ExternalFrom_Facebook, _Web3, _Twitch, _Discourse, _Ios, _Android, _Amb, _Wallet ``` --- ## 5. Contacts & Labels ```php // Add contact under a label $contact = new Users_Contact(); $contact->userId = $userId; $contact->label = 'Users/friends'; $contact->contactUserId = $friendId; $contact->save(true); // upsert // Remove contact from label Users_Contact::delete()->where(array( 'userId' => $userId, 'label' => 'Users/friends', 'contactUserId' => $friendId ))->execute(); // Fetch contacts under a label $contacts = Users_Contact::select('*')->where(array( 'userId' => $userId, 'label' => 'Users/friends' ))->fetchDbRows(); // Check if user can manage contacts (respects role hierarchy) $authorized = Users::canManageContacts( $asUserId, // who is trying $userId, // whose contacts $label, // which label false // throwIfNotAuthorized ); // Create a label $label = new Users_Label(); $label->userId = $communityId; $label->label = 'MyPlugin/vip'; $label->title = 'VIP Members'; $label->icon = 'labels/MyPlugin/vip'; $label->save(true); // Check if user can manage labels Users::canManageLabels($asUserId, $userId, 'MyPlugin/', false); // Returns true if $asUserId has a role with canManageLabels including 'MyPlugin/' ``` --- ## 6. Roles ```php // Get roles for a user in a community $roles = Users::roles($communityId, $userId); // Returns array of label strings: ['Users/members', 'Users/speakers'] // Check role if (in_array('Users/admins', Users::roles($communityId, $userId))) { ... } // Get all userIds holding specific roles $userIds = Users::byRoles( array('Users/admins', 'Users/owners'), // filter by roles array('communityId' => $communityId) ); ``` ```javascript // JS — roles loaded on page for logged-in user if (Q.Users.roles && Q.Users.roles['Users/admins']) { /* is admin */ } ``` Role hierarchy config (`Users.roles`): ```json { "Users/owners": { "canGrant": ["Users/admins", "Users/members", "Users/guests"], "canRevoke": ["Users/admins", "Users/members", "Users/guests"], "canSee": ["Users/owners", "Users/admins", "Users/members"], "canManageLabels": ["Users/"] } } ``` --- ## 7. Devices & Push Notifications ```php // Register a device (usually handled by client-side UsersDevice.js) $device = new Users_Device(); $device->userId = $userId; $device->deviceId = $pushToken; $device->platform = 'ios'; // ios, android, chrome, firefox, safari, web $device->appId = $appId; $device->sessionId = Q_Session::id(); $device->formFactor = 'mobile'; $device->save(true); // Push notification from PHP (goes through Node.js) Q_Utils::sendToNode(array( 'Q/method' => 'Users/pushNotifications', 'userId' => $userId, 'notification' => array( 'alert' => array('title' => 'Hello', 'body' => 'World'), 'badge' => 1 ) )); // Web Push fields: auth, p256dh (VAPID keys) ``` --- ## 8. Intents (Cross-Session Actions) ```php // Create an intent (e.g. for QR-code auth) $intent = Users_Intent::newIntent( 'Users/authenticate', // action array('platform' => 'web3'), // instructions array('duration' => 300) // options — 5 min window ); $token = $intent->token; // give this to the other device // Resolve an intent (from the other device/session) $intent = Users_Intent::fetch($token, true); // throws if missing $instructions = $intent->getAllInstructions(); // Complete an intent $intent->complete(array('userId' => $userId)); // Sets completedTime, notifies originating session via socket // Check validity $intent->isValid(); // false if expired or already completed ``` --- ## 9. Referrals ```php // Record a referral (usually called by Streams plugin on invite accept) Users_Referred::handleReferral( $userId, // the user who was referred $communityId, // to which community 'Streams/invite/accept', // action that triggered it 'Streams/chat', // type context array('invitingUserId' => $referrerId) ); // Walks the referral chain, awards points based on config // Look up who referred a user $referrer = Users_Referred::referrer($userId, $communityId); // Returns Users_Referred row or null // Points config (plugin.json): // "Users": { "referred": { "Streams/invite/accept": { "": { "points": 1 } } } } ``` --- ## 10. Voting & Totals ```php // Cast a vote (auto-updates Users_Total) Users_Vote::vote( 'MyPlugin/likes', // forType array($streamName), // forId (array) array(1), // values (array) array(1), // weights (array) $userId // voter ); // Read aggregated total $total = new Users_Total(); $total->forType = 'MyPlugin/likes'; $total->forId = $streamName; if ($total->retrieve()) { $count = (int)$total->voteCount; $avg = (float)$total->value; // weighted average $sum = (float)$total->weightTotal; // sum of weights } // NEVER update Users_Total directly — it's maintained by Users_Vote hooks // Check if user already voted $vote = new Users_Vote(); $vote->userId = $userId; $vote->forType = 'MyPlugin/likes'; $vote->forId = $streamName; $alreadyVoted = $vote->retrieve(); ``` --- ## 11. Email & Mobile ```php // Add email (sends activation code) $user->addEmail($emailAddress); // Fires Users/addIdentifier {after} // Set verified email directly (admin use) $user->setEmailAddress($emailAddress, true); // Add mobile $user->addMobile($mobileNumber); // Set verified mobile directly $user->setMobileNumber($mobileNumber, true); // Remove identifiers $user->removeEmail($emailAddress); $user->removeMobile($mobileNumber); // Look up user by identifier $user = Users::userFromContactInfo('email', $emailAddress); $user = Users::userFromContactInfo('mobile', $mobileNumber); // Identify table lookup (type-prefixed) $ui = Users::identify('email', $emailAddress, 'verified'); if ($ui) { $userId = $ui->userId; } // Future users (placeholder for pre-invited people) $user = Users::futureUser('email', 'alice@example.com', $status, $inserted); // Creates user + identify row in 'future' state if not exists // When real user registers with this email, they "adopt" the future user ``` --- ## 12. Passphrase Handling ```php // Hash a passphrase $hash = $user->hashPassphrase($passphrase, 'password_hash'); // Verify $valid = $user->verifyPassphrase($user->passphraseHash, $passphrase, $isHashed); // Prepare passphrase (normalize from client) $passphrase = $user->preparePassphrase($raw, $isHashed); // Config: // Users.passphrase.algorithms.password_hash.algorithm = "default" (bcrypt) // Users.passphrase.algorithms.hash_pbkdf2.iterations = 64000 ``` --- ## 13. Capabilities ```php // Generate capability for current request $cap = Users::capability(); // Returns signed token encoding socket permissions // Config: Q.capability.permissions maps route letters to permission names: // "u" => "Users/socket", "s" => "Streams/observe", "a" => "Users/authenticate" ``` --- ## 14. Quotas (Rate Limiting) ```php // Check and enforce quota $quota = Users_Quota::check($userId, 'Streams/invite', true); // true = throw Users_Exception_Quota if exceeded // Config in plugin.json: // "Users": { "quotas": { "Streams/invite": { "86400": { "": 10, "Users/admins": 1000 } } } } // Format: { "seconds": { "label_or_empty": limit } } ``` --- ## 15. Deliver Notifications ```php // Send notification to user via best available channel Users::deliver( $userId, 'Streams', // module 'chat/message', // event name array( // template fields 'displayName' => $name, 'content' => $messageContent ), array('Streams/content'), // text array for subject lookup false // usePending ); ``` --- ## 16. Web3 ```php // Cached blockchain query $web3 = new Users_Web3(); $web3->chainId = '0x89'; $web3->contract = $contractAddress; $web3->methodName = 'balanceOf'; $web3->params = Q::json_encode(array($address)); if ($web3->retrieve()) { $result = $web3->result; } // Transaction tracking $tx = new Users_Web3Transaction(); $tx->chainId = '0x89'; $tx->transactionId = $txHash; $tx->status = 'pending'; // 'signed', 'pending', 'mined', 'rejected' $tx->contract = $contractAddress; $tx->methodName = 'transfer'; $tx->fromAddress = $sender; $tx->save(true); // Chain config access $chain = Q_Config::get('Users', 'web3', 'chains', '0x89', array()); $rpcUrl = $chain['publicRPC']; $explorer = $chain['blockExplorerUrl']; ``` --- ## 17. Key Utility Methods ```php // Normalize identifier type $type = Users::identifierType($input, $normalized); // Returns 'email' or 'mobile', $normalized gets cleaned value // Request identifier from current request $identifier = Users::requestedIdentifier($type); // Icon URL for a user $url = Users::iconUrl($user->icon, '80.png'); $url = $user->iconUrl('80.png'); // Import icon from URL Users::importIcon($user, array('https://example.com/photo.jpg'), $directory); // Split name $parts = Streams::splitFullName('John Doe'); // Returns array('first' => 'John', 'last' => 'Doe', ...) ``` --- ## 18. Common Mistakes | Wrong | Right | |-------|-------| | `Users::loggedInUser()` without checking null | `Users::loggedInUser(true)` throws; `false` returns null — pick one | | `$user->save()` to update email/mobile | Use `$user->setEmailAddress()` / `setMobileNumber()` — handles activation flow | | Writing to `users_total` directly | Use `Users_Vote::vote()` — totals auto-update via hooks | | `$user->passphraseHash = hash(...)` | Use `$user->hashPassphrase($pass)` — handles salt, iterations, algorithm | | Checking roles with `Users_Contact` queries | Use `Users::roles($communityId, $userId)` — handles label hierarchy | | `Users::identify('email', $raw)` with un-normalized email | Normalize first: `Q_Valid::email($raw, $normalized)` | | Granting roles without checking `canGrant` | Use `Users::canManageContacts()` — enforces role hierarchy | | `$user->xids = json_encode(...)` directly | Use `$user->setXid($platformApp, $xid)` / `clearXid()` | | Creating future user without `Users::identify()` check | Use `Users::futureUser()` — handles dedup and adopt logic | --- ## 19. Key Schema ### users_user ```sql id varbinary(31) PK username varchar(63) UNIQUE emailAddress varbinary(255) NULL mobileNumber varbinary(255) NULL emailAddressPending varbinary(255) DEFAULT '' mobileNumberPending varbinary(255) DEFAULT '' signedUpWith varchar(31) DEFAULT 'none' -- 'email','mobile','facebook','web3',... passphraseHash varchar(255) NULL icon varbinary(255) xids varchar(1023) DEFAULT '{}' -- JSON {platform_appId: [xid,...]} sessionId varbinary(255) NULL sessionCount int DEFAULT 0 preferredLanguage varchar(3) DEFAULT 'en' salt varbinary(63) NULL insertedTime timestamp updatedTime timestamp NULL ``` ### users_identify ```sql identifier varbinary(255) PK -- type-prefixed: "email:alice@example.com" state enum('verified','future','unlinked') userId varbinary(31) insertedTime timestamp updatedTime timestamp NULL ``` ### users_contact ```sql userId varbinary(31) PK label varchar(63) PK -- 'Users/friends', 'MyPlugin/vip', etc. contactUserId varbinary(31) PK nickname varchar(255) DEFAULT '' insertedTime timestamp ``` ### users_label ```sql userId varbinary(31) PK label varchar(63) PK icon varbinary(255) DEFAULT 'default' title varchar(255) insertedTime timestamp updatedTime timestamp NULL ``` ### users_external_from ```sql platform varchar(31) PK appId varbinary(200) PK xid varbinary(31) PK userId varbinary(31) accessToken varchar(1023) NULL extra varchar(1023) DEFAULT '{}' -- JSON insertedTime timestamp updatedTime timestamp NULL ``` ### users_device ```sql userId varbinary(31) PK deviceId varbinary(700) PK -- push token or Web Push endpoint URL platform varchar(31) -- ios, android, chrome, firefox, safari, web appId varchar(200) NULL sessionId varbinary(255) formFactor enum('mobile','tablet','desktop') auth varchar(31) DEFAULT '' -- Web Push auth p256dh varchar(1023) NULL -- Web Push ECDH key ``` ### users_intent ```sql token varbinary(255) PK action varbinary(31) -- 'Users/authenticate', 'Users/bridge' instructions varbinary(2047) -- JSON (hidden from token holder) url varbinary(2083) NULL sessionId varbinary(255) NULL userId varbinary(31) NULL startTime timestamp NULL endTime timestamp NULL completedTime timestamp NULL insertedTime timestamp updatedTime timestamp NULL ``` ### users_referred ```sql userId varbinary(31) PK toCommunityId varbinary(31) PK referredByUserId varbinary(31) PK -- NOT byUserId or invitingUserId insertedTime timestamp updatedTime timestamp NULL points decimal(4,2) NULL qualifiedTime timestamp NULL extra varchar(1023) DEFAULT '{}' ``` ### users_session ```sql id varbinary(255) PK content varchar(4095) -- JSON (JS-readable) php varchar(4095) -- PHP serialized userId varbinary(31) NULL deviceId varbinary(700) platform varchar(31) NULL appId varchar(200) NULL formFactor enum('mobile','tablet','desktop') ipv4 varbinary(16) NULL ipv6 varbinary(64) NULL duration int DEFAULT 0 insertedTime timestamp updatedTime timestamp ``` # Streams Plugin — LLM Coding Primer Supplement to the Q Framework primer. Covers streams, messages, relations, access, invitations, subscriptions, participants, real-time, forking, and workspaces. Read before writing Streams-related code. --- ## 1. Stream Lifecycle ```php // Create $stream = Streams::create($asUserId, $publisherId, 'MyPlugin/thing', array( 'title' => 'Title', 'content' => 'Description', 'attributes' => Q::json_encode(array('foo' => 'bar')), 'readLevel' => 40, // messages (public) 'writeLevel' => 10, // join 'adminLevel' => 20 // invite ), array( 'relate' => array( // auto-relate to a category 'publisherId' => $catPubId, 'streamName' => $catName, 'type' => 'MyPlugin/items', 'weight' => time() ), 'skipAccess' => false )); // Fetch one — these are equivalent: $stream = Streams::fetchOne($asUserId, $publisherId, $streamName); // null if missing $stream = Streams_Stream::fetch($asUserId, $publisherId, $streamName); // same thing $stream = Streams::fetchOne($asUserId, $publisherId, $streamName, true); // throws if missing // Streams::fetchOne() calls Streams_Stream::fetch() internally — they are aliases. // Both enforce access control via calculateAccess(). // Fetch or create (idempotent) $stream = Streams::fetchOneOrCreate($asUserId, $publisherId, $streamName, array( 'fields' => array('type' => 'MyPlugin/thing', 'title' => 'T') ), $result); if ($result['created']) { /* first time */ } // Fetch multiple $streams = Streams::fetch($asUserId, $publisherId, array($name1, $name2)); // Returns array keyed by stream name, nulls for missing ones // Update attributes (THE correct way) $stream->setAttribute('foo', 'bar'); $stream->changed($asUserId); // saves + posts Streams/changed message + fires hooks // WRONG: $stream->save() — skips message posting and hooks // Update fields $stream->title = 'New Title'; $stream->content = 'Updated'; $stream->changed($asUserId, array('title', 'content')); // Close Streams::close($asUserId, $publisherId, $streamName); // Posts Streams/closed message, sets closedTime // Reopen a closed stream (clear closedTime to bring it back) $stream = Streams_Stream::fetch($asUserId, $publisherId, $streamName); if ($stream && $stream->closedTime) { $stream->closedTime = null; $stream->save(); // save() is correct here — no Streams::reopen() method exists } // Convention: Streams_stream_post handler does this automatically when // a POST names an existing closed stream — treats creation as reopening. ``` **Naming convention:** `name` always starts with the type prefix: `type: "MyPlugin/thing"` → `name: "MyPlugin/thing/{id}"`. If you omit the `name` field, `beforeSave` auto-generates a unique name prefixed with `{type}/Q`: ```php // Auto-generated name — just omit 'name': $stream = Streams::create($asUserId, $publisherId, 'MyPlugin/thing', array( 'title' => 'Title' )); // $stream->name will be something like "MyPlugin/thing/Qabcdef123" // Explicit name — set it yourself: $stream = Streams::create($asUserId, $publisherId, 'MyPlugin/thing', array( 'name' => 'MyPlugin/thing/my-custom-id', 'title' => 'Title' )); ``` --- ## 2. Messages ```php // Post a message to a stream $stream->post($asUserId, array( 'type' => 'MyPlugin/thing/updated', 'content' => 'Short human-readable text', // varchar(4095) 'instructions' => Q::json_encode($machineData) // varchar(8191) ), true); // true = skip access check // Returns Streams_Message. ordinal auto-assigned (never set manually). // Static post (no stream object needed) Streams_Message::post($asUserId, $publisherId, $streamName, array( 'type' => 'MyPlugin/thing/updated', 'content' => 'Updated' ), true); // Post to multiple streams at once Streams_Message::postMessages($asUserId, array( $publisherId => array( $streamName1 => array('type' => 'Streams/changed'), $streamName2 => array('type' => 'Streams/changed') ) ), true); // Fetch messages (follows fork chain transparently) $messages = Streams_Message::fetch($publisherId, $streamName, array( 'limit' => 50, 'ascending' => false, // newest first 'type' => 'Streams/chat/message' // optional filter )); // Fetch by ordinal range $messages = Streams_Message::fetch($publisherId, $streamName, array( 'ordinalMin' => 10, 'ordinalMax' => 20 )); // Message totals (count by type) $total = new Streams_MessageTotal(); $total->publisherId = $publisherId; $total->streamName = $streamName; $total->messageType = 'Streams/chat/message'; if ($total->retrieve()) { $count = $total->messageCount; } ``` Built-in message types: `Streams/created`, `Streams/changed`, `Streams/closed`, `Streams/joined`, `Streams/left`, `Streams/relatedTo`, `Streams/relatedFrom`, `Streams/unrelatedTo`, `Streams/chat/message`, `Streams/chat/edit`, `Streams/chat/remove`, `Streams/invited`, `Streams/subscribed`, `Streams/forked`. --- ## 3. Relations ```php // Relate child (from) to parent (to) Streams::relate( $asUserId, $toPublisherId, $toStreamName, // PARENT / category 'MyPlugin/items', // relation type $fromPublisherId, $fromStreamName, // CHILD / member array('weight' => time()) // weight for ordering ); // Unrelate Streams::unrelate( $asUserId, $toPublisherId, $toStreamName, 'MyPlugin/items', $fromPublisherId, $fromStreamName ); // Fetch related (full stream objects + relations) list($relations, $relatedStreams, $stream) = Streams::related( $asUserId, $publisherId, $streamName, 'MyPlugin/items', true, // isCategory: true → fetch children (from) array( 'limit' => 50, 'offset' => 0, 'ascending' => false, // by weight DESC 'streamsOnly' => false, 'relationsOnly' => false ) ); // $relations: array of Streams_RelatedTo rows // $relatedStreams: array of Streams_Stream objects keyed "publisherId\tname" // $stream: the parent stream // Update relation weight Streams::updateRelation( $asUserId, $toPublisherId, $toStreamName, 'MyPlugin/items', $fromPublisherId, $fromStreamName, $newWeight, 1 // adjustWeights ); // Cheap query (no stream fetch, no access check) $rows = Streams_RelatedTo::select('*')->where(array( 'toPublisherId' => $publisherId, 'toStreamName' => $streamName, 'type' => 'MyPlugin/items' ))->orderBy('weight', false)->fetchDbRows(); // Each row: ->fromPublisherId, ->fromStreamName, ->type, ->weight ``` **Direction convention:** `fromStream` = child/member, `toStream` = parent/category. `isCategory=true` → fetch children. In JS callback: use `this.from` for children. --- ## 4. Relations — JavaScript ```javascript // CRITICAL: callback is function(errorMessage) — NOT function(err, result) Q.Streams.related( publisherId, streamName, 'MyPlugin/items', true, // isCategory: true → children { limit: 50 }, function (errorMessage) { if (errorMessage) return console.warn(errorMessage); var result = this; // result.relations — array // result.relatedStreams — keyed "publisherId\tstreamName" // result.stream — the parent Q.each(result.relations, function () { var child = this.from; // isCategory=true → .from // this.to → use when isCategory=false }); } ); // Relate Q.Streams.relate(publisherId, streamName, relationType, fromPublisherId, fromStreamName, callback); // Unrelate Q.Streams.unrelate(publisherId, streamName, relationType, fromPublisherId, fromStreamName, callback); ``` --- ## 5. Access Control Numeric levels — use string constants when testing: | Value | Read | Write | Admin | |-------|------|-------|-------| | 0 | none | none | none | | 10 | see | join | tell | | 13 | — | vote | — | | 15 | teaser | suggest | share | | 16 | — | ephemeral | — | | 18 | — | contribute | — | | 19 | — | fork | — | | 20 | relations | post | invite | | 23 | content | relate | — | | 25 | fields | relations | — | | 30 | participants | edit | manage | | 35 | messages | closePending | — | | 40 | receipts | close | own | ```php // Test access (returns bool) $stream->testReadLevel('content'); // >= 23 $stream->testWriteLevel('post'); // >= 20 $stream->testWriteLevel('relate'); // >= 23 $stream->testWriteLevel('edit'); // >= 30 $stream->testAdminLevel('invite'); // >= 20 $stream->testAdminLevel('manage'); // >= 30 $stream->testAdminLevel('own'); // >= 40 $stream->testPermission('myCustomPerm'); // Get numeric level $level = $stream->getReadLevel(); // returns int $level = $stream->getWriteLevel(); $level = $stream->getAdminLevel(); // Grant access to a specific user $access = new Streams_Access(); $access->publisherId = $publisherId; $access->streamName = $streamName; $access->ofUserId = $userId; $access->readLevel = 40; // messages $access->writeLevel = 20; // post $access->adminLevel = -1; // -1 = don't override $access->grantedByUserId = $grantingUserId; $access->save(true); // Grant access to everyone under a label $access = new Streams_Access(); $access->publisherId = $publisherId; $access->streamName = $streamName; $access->ofContactLabel = 'Users/members'; $access->readLevel = 40; $access->writeLevel = 20; $access->adminLevel = -1; $access->save(true); ``` **Access resolution order:** public (stream fields) → contact labels (max across all) → participant → direct (ofUserId) → inherited. Final = max across all sources. `-1` means "no opinion" (ignored in max calculation). **Templates:** stream with `name = "MyPlugin/thing/"` → default access/fields for all `MyPlugin/thing` streams. `name = "MyPlugin/thing/*"` → mutable access computed at fetch time. --- ## 6. Participants ```php // Join a stream Streams::join($asUserId, $publisherId, array($streamName), array( 'subscribed' => false, // also subscribe? 'reason' => 'Joined' )); // Shortcut on stream object: $stream->join(array('userId' => $asUserId)); // Leave Streams::leave($asUserId, $publisherId, array($streamName)); $stream->leave(array('userId' => $asUserId)); // Check participation $participant = $stream->participant($userId); // returns Streams_Participant or null if ($participant && $participant->state === 'participating') { ... } // participant->state: 'invited' | 'participating' | 'left' (NOT 'joined') // participant->subscribed: 'yes' | 'no' // participant->posted: 'yes' | 'no' // participant->extra: JSON string // Fetch all participants $participants = Streams_Participant::select('*')->where(array( 'publisherId' => $publisherId, 'streamName' => $streamName, 'state' => 'participating' ))->fetchDbRows(); // Get just user IDs $userIds = Streams_Participant::getUserIds($publisherId, $streamName); // Filter users by participation $participating = Streams_Participant::filter( $userIds, $publisherId, $streamName, 'participating' ); ``` --- ## 7. Subscriptions & Notifications ```php // Subscribe (auto-joins if needed) Streams::subscribe($asUserId, $publisherId, array($streamName), array( 'filter' => array( 'types' => array('Streams/chat/message', 'Streams/relatedTo'), 'notifications' => 0 ), 'untilTime' => strtotime('+30 days') )); // Shortcut: $stream->subscribe(array('userId' => $asUserId)); // Unsubscribe Streams::unsubscribe($asUserId, $publisherId, array($streamName)); // Check subscription $sub = $stream->subscription($userId); // returns Streams_Subscription or null // Subscription rules (per-user notification delivery config) // rule.filter: JSON {"types": [...], "labels": [...]} // rule.deliver: JSON {"to": "default"} or {"email": true, "mobile": true, "devices": true} // rule.readyTime: DND — rule inactive before this timestamp ``` **Notification pipeline:** Message posted → find subscribed participants → evaluate subscription filter → evaluate delivery rules → dispatch via devices/email/mobile. Config: `Streams.notifications.onlyIfAllClientsOffline` — suppress push if user has active socket. --- ## 8. Invitations ```php // Invite users to a stream $invites = Streams::invite($publisherId, $streamName, array( // who to invite 'userId' => array($userId1, $userId2), // OR: 'identifier' => array('alice@example.com'), // OR: 'label' => 'Users/members', ), array( 'readLevel' => 40, 'writeLevel' => 20, 'adminLevel' => 20, 'appUrl' => $url, 'subscribe' => true ) ); // Returns array of Streams_Invite objects // Accept invite (usually handled by Streams_before_Q_objects) $invite = Streams_Invite::fromToken($token, true); $invite->accept(array( 'subscribe' => true, // auto-subscribe 'access' => true // upgrade access levels )); // invite->state: 'pending' | 'accepted' | 'declined' | 'arrived' | 'forwarded' | 'expired' // Check pending invites for a stream $invites = Streams_Invite::forStream($publisherId, $streamName, $userId); // Invite URL $url = Streams::inviteUrl($token); // e.g. https://app.example/i/abcdef... // Request access (reverse of invite — user asks for access) Streams::request($publisherId, $streamName, array( 'readLevel' => 40, 'writeLevel' => 20 )); ``` --- ## 9. Real-Time (Observe / Neglect / Ephemeral) ```javascript // Observe a stream (register for socket updates) Q.Streams.Stream.observe(publisherId, streamName, function () { // now receiving real-time messages, field changes, relation updates }); // Neglect (stop observing) Q.Streams.Stream.neglect(publisherId, streamName); // Observe is automatic with Q.Streams.get() unless {dontObserve: true} Q.Streams.retainWith(tool).get(publisherId, streamName, function () { var stream = this; // stream is observed + retained for tool lifetime // neglect + release happen automatically on tool removal }); // Listen for messages on a stream stream.onMessage('MyPlugin/thing/updated').set(function (msg) { var inst = JSON.parse(msg.instructions || '{}'); // msg.ordinal, msg.type, msg.content, msg.byUserId }, tool); // Listen for ephemerals on a stream stream.onEphemeral('MyPlugin/thing/updated').set(function (e) { var inst = JSON.parse(msg.instructions || '{}'); // e.payload, e.type, e.content, e.byUserId }, tool); // Field change events Q.Streams.Stream.onFieldChanged(pubId, name, 'title').set(function (fields) { // fields.title = new value }, tool); // Attribute change events Q.Streams.Stream.onUpdated(pubId, name, 'attrName').set(function (attrs) { // attrs.attrName = new value }, tool); ``` ```php // Ephemeral (non-persisted broadcast to observers) // Requires writeLevel >= 'ephemeral' (16) // Configured per stream type in "ephemerals" config // Used for: typing indicators, cursor positions, playback sync // Sent via socket from client, broadcast to all observers, never stored in DB ``` --- ## 10. Forking ```php // Fork a stream at a specific ordinal $fork = Streams::fork( $asUserId, $publisherId, $streamName, // source stream $ordinal, // split point $toPublisherId, // destination publisher $toStreamName // optional — defaults to same name ); // Requires writeLevel >= 'fork' (19) on source // Fork copies all fields, sets fork JSON with provenance chain, // starts messageCount at $ordinal, relates to source via 'Streams/fork', // inherits access from source, copy-on-write file attachments via symlinks. // Message fetch on forked stream transparently walks the fork chain: // messages 0..ordinal-1 from source, ordinal+ from fork $messages = Streams_Message::fetch($toPublisherId, $toStreamName); // Returns unified sequence across all ancestors // Read fork chain $chain = Streams::forkChain($publisherId, $streamName); // Returns array of [publisherId, streamName, ordinalMin, ordinalMax, closedTime] ``` --- ## 11. Workspaces ```php // Workspaces are virtual publisher namespaces: "alice~ws2" // Client passes workspaces[] query parameter for overlay semantics // Get workspace stack from request $workspaces = Streams_Workspace::fromRequest(); // Returns array of workspace names, top of stack first // Build cascade publisherIds $ids = Streams_Workspace::stackedPublisherIds('alice', array('ws2', 'ws1')); // Returns ['alice~ws2', 'alice~ws1', 'alice'] // First match wins in SELECT; writes go to topmost workspace // Create workspace Streams_Workspace::ensure('ws2', 'ws1'); // ws2 with parent ws1 // Parse workspace from publisherId $wsName = Streams_Workspace::nameFromPublisherId('alice~ws2'); // 'ws2' ``` --- ## 12. User Streams (Auto-Created on Registration) When a user registers, these streams are created under their publisherId: | Stream Name | Type | Purpose | |---|---|---| | `Streams/user/firstName` | `Streams/text/name` | First name | | `Streams/user/lastName` | `Streams/text/name` | Last name | | `Streams/user/username` | `Streams/text/name` | Username | | `Streams/user/icon` | `Streams/image` | Profile icon | | `Streams/user/emailAddress` | `Streams/text/emailAddress` | Email (private, readLevel 0) | | `Streams/user/mobileNumber` | `Streams/text/mobileNumber` | Mobile (private, readLevel 0) | | `Streams/invited` | `Streams/invited` | Invite notifications | | `Streams/mentioned` | `Streams/mentioned` | @mention notifications | | `Streams/participating` | `Streams/participating` | Category of participating streams | | `Streams/contacts` | `Streams/resource` | Contact management access | | `Streams/labels` | `Streams/resource` | Label management access | ```php // Read a user's personal stream $firstName = Streams::my('Streams/user/firstName', 'content'); // OR: $stream = Streams::fetchOne($asUserId, $userId, 'Streams/user/firstName', true); $firstName = $stream->content; // Sync: Users_User.username ↔ Streams/user/username (bidirectional) // Config: Streams.onUpdate.Users_User.username = "Streams/user/username" ``` --- ## 13. Stream Type Configuration ```json { "Streams": { "types": { "MyPlugin/thing": { "emoji": "🎯", "create": true, "edit": ["title", "content"], "defaults": { "title": "Untitled", "icon": "MyPlugin/thing", "readLevel": 40, "writeLevel": 10, "adminLevel": 20 }, "messages": { "MyPlugin/thing/updated": { "description": "Thing was updated", "post": true } }, "ephemerals": { "MyPlugin/typing": {} }, "subscriptions": { "filter": { "types": ["MyPlugin/thing/updated"], "notifications": 0 } }, "admins": ["Users/owners", "Users/admins"], "canCreate": ["Users/owners", "Users/admins"], "participating": ["Streams/participating"], "observersMax": 100, "extend": ["MyPlugin_Thing"] } } } } ``` `create` — `true`, `false`, or array of fields settable on create. `edit` — `true`, `false`, or array of editable fields. `extend` — PHP class names mixed into the stream (for `beforeSave`, custom methods). `defaults` — field values for new streams of this type. `messages` — declare valid message types; `"post": true` allows client-side posting. `ephemerals` — declare valid ephemeral types for real-time broadcast. `participating` — which category stream to auto-relate to when user participates. **User streams config (`streams.json`):** Plugins and apps declare user-specific streams in files listed under `Streams/userStreams` config. These are merged by `Streams::userStreamsTree()` and define streams that can be created with explicit names (e.g. `Streams/user/firstName`). The `possibleUserStreams` config lists which stream names a user is allowed to set explicitly when creating via the HTTP POST handler — the user must also have `adminLevel >= own` on the template. --- ## 14. Avatars ```php // Fetch avatar (cached user display info scoped per viewer) $avatars = Streams_Avatar::fetch($toUserId, array($publisherId1, $publisherId2)); // Returns array of Streams_Avatar objects keyed by publisherId // Each: ->username, ->firstName, ->lastName, ->icon // Get display name (convenience) $name = Streams::displayName($userId, array('short' => true)); // Update avatars (called automatically on access changes) Streams_Avatar::updateAvatars($publisherId, $accessRows, $streamName); ``` --- ## 15. Hooks — Common Patterns Register in `plugin.json`: ```json { "Q": { "handlersAfterEvent": { "Streams/create/MyPlugin/thing": ["MyPlugin/after/Streams_create_MyPlugin_thing"], "Streams/message/MyPlugin/thing/updated": ["MyPlugin/after/Streams_message_MyPlugin_thing_updated"], "Streams/relateTo/MyPlugin/thing": ["MyPlugin/after/Streams_relateTo_MyPlugin_thing"] } } } ``` ```php // After stream creation hook function MyPlugin_after_Streams_create_MyPlugin_thing($params) { $stream = $params['stream']; $stream->setAttribute('initialized', true); $stream->changed($params['asUserId']); } // After message posted hook function MyPlugin_after_Streams_message_MyPlugin_thing_updated($params) { $message = $params['message']; $stream = $params['stream']; $instructions = $message->getAllInstructions(); } // Before stream save hook (validation) // File: handlers/MyPlugin/before/Streams_Stream_save_MyPlugin_thing.php function MyPlugin_before_Streams_Stream_save_MyPlugin_thing($params) { $stream = $params['stream']; $modifiedFields = $params['modifiedFields']; if (empty($modifiedFields['title'])) { throw new Q_Exception_RequiredField(array('field' => 'title')); } } ``` --- ## 16. syncRelations (Auto-Relations from Attributes) Stream types can declare that changing certain attributes automatically maintains relations. Config-driven — no manual `Streams::relate()` calls needed. ```json { "Streams": { "types": { "MyPlugin/thing": { "syncRelations": ["from"], "attributes": { "MyPlugin/categories": { "type": "array", "syncRelations": { "toStreamName": "MyPlugin/categories", "relationType": "MyPlugin/category" } }, "MyPlugin/tags": { "type": "array", "syncRelations": { "toStreamName": "MyPlugin/tags", "relationType": "attribute/MyPlugin/tags" } } } } } } } ``` When `Safebox/categories` attribute changes on a stream, the framework automatically computes which relations to add/remove and calls `Streams::relate()`/`Streams::unrelate()` within a transaction. Relation types follow the pattern `attribute/{name}={value}`. --- ## 17. Client-Side Stream Constructors ```javascript // Define a constructor for a stream type (like Q.Tool.define for tools) Q.Streams.define("Chess/game", function (fields) { // Set extended fields this.fields.moves = fields.moves; // Subscribe to messages — update local state to match server this.onMessage('Chess/move').set(function (stream, message) { stream.moves += "\n" + message.instructions.move; }, 'Chess/game'); }); // WRONG: Do NOT add events directly to stream objects this.onSomething = new Q.Event(); // ← breaks on refresh (new objects replace cached ones) // Convention: message handlers must "apply the diff" — // update local state so it matches what a full refresh would return. ``` --- ## 18. Common Mistakes | Wrong | Right | |-------|-------| | `$stream->save()` after `setAttribute()` | `$stream->changed($userId)` — saves + posts message + hooks | | `$stream->fields->name` (in JS) | `stream.fields.name` — no `streamName` property | | `Streams::related(..., function(err, result){})` (JS) | `function(errorMessage){ var result = this; }` | | `this.to` when `isCategory=true` (JS) | `this.from` — children are "from" streams | | Setting `ordinal` manually on messages | Never — auto-assigned by `Streams_Message::post()` | | `participant->state === 'joined'` | State is `'participating'` (not 'joined') | | `Streams_Access` with readLevel = 0 to mean "inherit" | Use `-1` for "don't override"; `0` = `none` (actively blocked) | | Fetching messages without considering fork chain | `Streams_Message::fetch()` handles it transparently | | Creating streams without type prefix in name | Name must start with type: `MyPlugin/thing/{id}` (or omit name for auto-generation) | | `$stream->post(...)` without skipAccess on system messages | Pass `true` as third arg for internal/system messages | | Querying `Streams_RelatedFrom` for category contents | Use `Streams_RelatedTo` — it's on the category publisher's shard | | Granting access without `grantedByUserId` | Always set — tracks who authorized the access | | Setting `closedTime` directly + `save()` | Use `Streams::close($asUserId, $pub, $name)` — posts message + hooks | | Manually relating on attribute change | Use `syncRelations` config — framework auto-maintains relations | | Adding `new Q.Event()` on stream objects (JS) | Breaks on refresh — use `stream.onMessage()` factory instead | | Using `Streams::related()` for hot category pages | Use `Streams_Category::getRelatedTo()` for denormalized high-perf index | # Metrics Plugin — LLM Coding Primer Supplement to the Q Framework primer. Covers anonymous attribution tracking: trackers, visits, hits, actions, traits, conversions, visit chaining, and error telemetry. No dependency on Users or Streams — Metrics runs standalone. --- ## 1. Automatic Hit Recording Every web request is tracked automatically. The `Q/metrics` after-handler fires on every `Q_WebController` request: ```php // This runs automatically — you don't call it. // Reads trackerId from $_GET['sourceId'] (configurable), // resolves URL to a Metrics_Action, records a Metrics_Hit // under the current Metrics_Visit (creating one if needed). ``` Visitors arrive with a tracker via querystring: `?sourceId=myTracker`. The field name is configurable: ```json { "Metrics": { "querystring": { "fields": { "trackerId": ["sourceId"] } } } } ``` Multiple field names can be listed — the first non-empty match wins. Both `$_GET` and the Qbix internal URI (`Q_Dispatcher::uri()`) are checked. --- ## 2. Trackers A tracker is a named attribution source — a campaign, a link, a QR code. ```php // Create a tracker $tracker = new Metrics_Tracker(); $tracker->id = 'greg/poker-night'; // free-form varbinary(63) $tracker->publisherId = $userId; // who owns this tracker $tracker->sentCount = 50; // how many people you sent it to $tracker->visitsCount = 0; // auto-incremented as visits arrive $tracker->save(); // Retrieve $tracker = new Metrics_Tracker(); $tracker->id = 'greg/poker-night'; if ($tracker->retrieve()) { $visits = $tracker->visitsCount; } ``` The `id` field is free-form. Convention is `publisherId/someName` but any string up to 63 bytes works. --- ## 3. Visits A visit is one browsing session. Stored in `$_SESSION` and reused for the duration of the PHP session. ```php // Get or create the current visit (usually called internally) $visit = Metrics_Visit::current($trackerId); // If no visit exists in session, creates one with auto-generated id (prefix 'v-') // If visit exists, returns it (ignores $trackerId — first one wins) // Check current visit ID from session without creating $id = Metrics_Visit::currentId(); // string or null // Visit chaining — resolve parent visit $parentId = $visit->referringVisitId(); // Returns the parent visitId if trackerId starts with "visitId:", else "" ``` Visit IDs are auto-generated in `beforeSave` via `Metrics::db()->uniqueId()` with a `v-` prefix. The `Metrics/Visit/id` before-event lets other plugins transform the ID (e.g. Users might prepend the logged-in userId). ### Fields populated automatically on creation ```php $visit->platform; // Q_Request::platform() $visit->formFactor; // Q_Request::formFactor() — 'desktop','mobile','tablet','other' $visit->IP; // Q_Request::ip() // Geo fields (countryCode, postcode, geonameId) are TODO stubs ``` --- ## 4. Hits A hit is a single page view within a visit. ```php // Register a hit from a URL (resolves to action, records hit) $hit = Metrics_Hit::registerUrl($url, $trackerId); // Register a hit from an action ID directly $hit = Metrics_Hit::register($actionId, $trackerId); // Both methods: // 1. Call Metrics_Visit::current($trackerId) — resumes or creates visit // 2. Insert hit row with {visitId, actionId, fromActionId, trackerId} // 3. Update visit's lastActionId and lastTime ``` The hit's `fromActionId` is the visit's `lastActionId` at the time of recording — giving you a page-to-page navigation chain within each visit. --- ## 5. Actions An action is a canonical URL identity. ```php // Resolve URL to action (creates if new) $action = Metrics_Action::fromUrl($url); // Sets $action->id (path-based), $action->url, $action->canonicalActionId (from Q_Uri) // Compute action ID from URL without database lookup $id = Metrics_Action::idFromUrl($url); // If path ≤ 63 bytes: returns path as-is // If path > 63 bytes: truncates + appends SHA1 hash suffix // Compute action ID from internal URI $id = Metrics_Action::idFromUri($uri); // Same truncation logic as idFromUrl // Hash suffix length is configurable: // "Metrics": { "action": { "id": { "length": 30 } } } ``` The `canonicalActionId` maps URL variants (different query strings) to the same logical action. It's derived from the Qbix internal URI (`Q_Uri::from()`). --- ## 6. Traits & Tracker-Trait Join Traits are named attribute/value pairs for slicing analytics across dimensions. ```php // Create a trait $trait = new Metrics_Trait(); $trait->id = 'medium_email'; // synthetic key $trait->name = 'medium'; // trait name $trait->content = 'email'; // trait value $trait->sentTotal = 0; $trait->visitsTotal = 0; $trait->save(); // Attach trait to tracker $tt = new Metrics_TrackerTrait(); $tt->trackerId = 'greg/poker-night'; $tt->traitId = 'medium_email'; $tt->save(); // Query: which trackers used email as medium? $rows = Metrics_TrackerTrait::select() ->where(array('traitId' => 'medium_email')) ->fetchDbRows(); // Query: what traits does a tracker have? $rows = Metrics_TrackerTrait::select() ->where(array('trackerId' => 'greg/poker-night')) ->fetchDbRows(); ``` --- ## 7. Conversions Aggregated conversion stats per trait/action pair. ```php // Read conversion data $conv = new Metrics_Conversion(); $conv->traitId = 'medium_email'; $conv->actionId = '/signup'; if ($conv->retrieve()) { $totalVisits = $conv->visitsTotal; // visits from email trackers that hit /signup $avgFromTracker = $conv->sinceTracker; // avg seconds from tracker creation to this hit $avgFromVisit = $conv->sinceVisit; // avg seconds from visit start to this hit } // Query: best-converting traits for an action $convs = Metrics_Conversion::select() ->where(array('actionId' => '/signup')) ->orderBy('visitsTotal', false) ->fetchDbRows(); ``` Note: conversion rows are not auto-populated — you write the aggregation logic yourself, typically in a cron job or an event handler. --- ## 8. Client-Side API (Metrics.js) ### State tracking ```javascript // Mark current hit with a state (debounced 5s, then POSTs to Metrics/update) Metrics.setState('completed', { score: 42 }); Metrics.setState('abandoned'); Metrics.setState('step2', { formField: 'email' }); Metrics.setState('error', { message: 'something broke' }); ``` The POST sends `{navigatorUrl, url, state, extra}`. The server matches against the last 5 hits for the current visit by `actionId` and updates `finalState` and `extra`. ### Visit chaining ```javascript // Automatic: if URL contains #v=parentVisitId, Metrics.js POSTs to // Metrics/landed with trackerId: 'visitId:' + parentVisitId // The response's visitId is written back into the hash for downstream pages. // To propagate a visit ID into a link you're building: var link = 'https://other-site.com/page#v=' + currentVisitId; ``` ### Error telemetry Global handlers for `window.error` and `unhandledrejection` are installed automatically. Errors POST to `Metrics/update` with `state: 'error'` and a structured payload containing message, stack, URL, userAgent, and timestamps. --- ## 9. Server-Side Handlers ### Metrics/landed (POST) Explicit landing attribution. Called by client code when the tracker isn't in the querystring (e.g. visit chaining via hash fragment). ```php // Requires: $_POST['trackerId'] // Gets/creates visit via Metrics_Visit::current($trackerId) // If visit had no trackerId, sets it and fires event: Q::event('Metrics/landed/visit/started', array('visit' => $visit)); // Returns: slot 'visitId' => $visit->id ``` ### Metrics/update (POST) Updates `finalState` and/or `extra` on a recent hit. ```php // Fields: url, navigatorUrl, state, extra // Finds matching hit among last 5 for current visit (by actionId) // Updates finalState and extra on that hit // Returns: slots 'updated' => bool, 'hitId' or 'error' ``` ### Metrics/before/Users_User_lastActiveTime Optional hook — only fires if Users plugin is loaded. Derives user activity time from visit data by querying visits whose ID starts with `userId;` (assumes visit ID was prefixed by Users plugin via the `Metrics/Visit/id` before-event). --- ## 10. Events ```php // Visit ID generation — lets other plugins transform the auto-generated ID // e.g. Users plugin might prepend userId Q::event('Metrics/Visit/id', array(), 'before'); // Return value replaces the generated ID // New visit started via explicit landing Q::event('Metrics/landed/visit/started', array('visit' => $visit)); ``` --- ## 11. Configuration ```json { "Metrics": { "action": { "id": { "length": 30 } }, "querystring": { "fields": { "trackerId": ["sourceId"] } }, "visit": { "enrich": { "location": true } } } } ``` | Key | Purpose | |---|---| | `action.id.length` | SHA1 hash suffix length when URL path exceeds 63 bytes. Default 30. | | `querystring.fields.trackerId` | Array of `$_GET` field names to check for tracker ID. Default `["sourceId"]`. | | `visit.enrich.location` | Whether to geo-enrich visits (stub — needs Places_IP2Location). | Plugin registration in `Q.pluginInfo`: ```json { "Metrics": { "version": "0.8", "requires": { "Q": "1.0" }, "connections": ["Metrics"] } } ``` No dependency on Users, Streams, or any other plugin. --- ## 12. Visit Chaining For cross-page or cross-origin attribution where the tracker can't be passed as a querystring parameter: ``` Page A (tracked link) → redirect → Page B (app page) visit 1 visit 2 trackerId: "greg/poker" trackerId: "visitId:v-a8f3kx" ``` Page B includes `#v=visit1Id` in the redirect URL. Metrics.js picks it up and POSTs to `Metrics/landed` with `trackerId: 'visitId:' + visit1Id`. The server stores this verbatim. To resolve the chain: ```php // Walk the chain to find the root tracker $visit = new Metrics_Visit(array('id' => $visitId)); $visit->retrieve(); while ($parentId = $visit->referringVisitId()) { $visit = new Metrics_Visit(array('id' => $parentId)); if (!$visit->retrieve()) break; } // $visit->trackerId is now the original attribution source ``` This is the anonymous equivalent of `Users_Referred`. When used with Streams invites, the Metrics chain provides attribution while the invite token provides access control. --- ## 13. Key Schema ### metrics_tracker ```sql id varbinary(63) PK -- free-form, e.g. "publisherId/someName" publisherId varbinary(31) -- who owns this tracker sentCount int -- estimated recipients visitsCount int -- total visits from this tracker insertedTime timestamp updatedTime timestamp NULL ``` ### metrics_visit ```sql id varbinary(63) PK -- auto-generated, prefix "v-" trackerId varbinary(63) -- tracker that brought this visitor, or "visitId:parentId" startTime timestamp NULL lastTime timestamp NULL lastActionId varbinary(63) -- most recent action hit platform varbinary(31) -- OS/browser formFactor enum('desktop','mobile','tablet','other') IP varbinary(255) -- IPv4 or IPv6 countryCode varchar(2) NULL postcode varchar(20) NULL geonameId int NULL ``` ### metrics_hit ```sql visitId varbinary(31) -- which visit actionId varbinary(63) -- URL path / action fromActionId varchar(63) NULL -- previous action in this visit trackerId varbinary(63) -- denormalized from visit insertedTime timestamp NULL finalState varchar(63) NULL -- e.g. 'completed', 'abandoned', 'error' extra varchar(1023) NULL -- JSON payload ``` Indexed on: `(visitId, insertedTime)`, `(actionId, visitId)`, `fromActionId`, `trackerId`, `insertedTime`. ### metrics_action ```sql id varbinary(63) PK -- derived from URL path url varbinary(2083) NULL -- original URL canonicalActionId varbinary(63) -- maps variants to one logical action ``` ### metrics_trait ```sql id varbinary(63) PK -- synthetic key name varchar(63) -- trait name: 'medium', 'utm_campaign', etc. content varbinary(63) -- trait value: 'email', 'summer2026', etc. sentTotal int -- aggregate across trackers with this trait visitsTotal int -- aggregate visits insertedTime timestamp updatedTime timestamp NULL ``` ### metrics_tracker_trait ```sql trackerId varbinary(63) PK -- FK to tracker traitId varbinary(63) PK -- FK to trait insertedTime timestamp updatedTime timestamp NULL ``` ### metrics_conversion ```sql traitId varbinary(63) PK -- which trait actionId varbinary(63) PK -- which action was hit visitsTotal int -- visits from trackers with this trait hitting this action sinceTracker decimal(10,4) -- avg seconds from tracker creation to hit sinceVisit decimal(10,4) -- avg seconds from visit start to hit insertedTime timestamp updatedTime timestamp NULL ``` --- ## 14. Common Patterns ```php // Track a custom action (not just page views) Metrics_Hit::register('checkout/complete', $trackerId); // Create a tracked short link $tracker = new Metrics_Tracker(); $tracker->id = $publisherId . '/' . $slug; $tracker->publisherId = $publisherId; $tracker->sentCount = count($recipients); $tracker->save(); // Build URL: https://yoursite.com/landing?sourceId=publisherId/slug // Attach traits for later slicing $trait = new Metrics_Trait(); $trait->id = 'medium_sms'; $trait->name = 'medium'; $trait->content = 'sms'; $trait->save(true); // upsert $tt = new Metrics_TrackerTrait(); $tt->trackerId = $tracker->id; $tt->traitId = $trait->id; $tt->save(); // Query all visits from a tracker $visits = Metrics_Visit::select() ->where(array('trackerId' => $tracker->id)) ->orderBy('startTime', false) ->fetchDbRows(); // Query hit funnel for a visit $hits = Metrics_Hit::select() ->where(array('visitId' => $visit->id)) ->orderBy('insertedTime', true) ->fetchDbRows(); ``` --- ## 15. Common Mistakes | Wrong | Right | |-------|-------| | Passing trackerId after visit already started | `Metrics_Visit::current()` ignores trackerId if visit exists in session — first call wins | | Expecting hit PK to be a single column | Hit PK is composite `(visitId, insertedTime)` — there is no auto-increment `id` | | Querying hits by `canonicalActionId` | Hits store `actionId` (the raw path), not `canonicalActionId` — join through `metrics_action` to group by canonical | | Updating `metrics_conversion` rows directly without averaging | `sinceTracker` and `sinceVisit` are running averages — use `(old * n + new) / (n+1)` | | Assuming visit geo fields are populated | `countryCode`, `postcode`, `geonameId` are stubs — need Places_IP2Location integration | | Relying on `#v=` hash for server-side logic | Hash fragments are never sent to the server — the client-side JS must POST to `Metrics/landed` | | Creating trackers with IDs > 63 bytes | `tracker.id` is `varbinary(63)` — will silently truncate | # Places Plugin — LLM Coding Primer Supplement to the Q Framework, Users, and Streams primers. Covers geolocation, the nearby grid, venue hierarchy, IP lookup, and geographic reference data. Read before writing Places-related code. --- ## 1. Nearby Grid — Publishing & Subscribing ```php // PUBLISHING: relate content to all nearby category streams $publisherId = Users::communityId(); $latitude = 40.7128; $longitude = -74.0060; // Get category streams to relate to (one per distance tier) $nearby = Places_Nearby::forPublishers($latitude, $longitude); // Returns array: streamName => {latitude, longitude, geohash, meters} // Stream name format: "Places/nearby/main/{geohash}/{meters}" // Relate your content stream to all nearby categories foreach ($nearby as $streamName => $info) { Streams::relate(null, $publisherId, $streamName, 'MyPlugin/events', $fromPublisherId, $fromStreamName, array('skipAccess' => true, 'weight' => time()) ); } // Or use the streams() helper to auto-create category streams + relate $streams = Places_Nearby::streams($publisherId, $latitude, $longitude, array( 'meters' => null // null = use all tiers from config )); // SUBSCRIBING: join/subscribe to the 2x2 grid block at one distance tier // $meters MUST be one of the values in Places/nearby/meters config Places_Nearby::subscribe($publisherId, $latitude, $longitude, $meters); Places_Nearby::unsubscribe($publisherId, $latitude, $longitude, $meters); // Or just join (no notifications) Places_Nearby::join($publisherId, $latitude, $longitude, $meters); ``` **Grid mechanics:** Publishers snap to one cell per tier. Subscribers join a 2×2 block anchored at the nearest grid corner. This guarantees the subscriber's circle of radius `$meters` is always fully covered. --- ## 2. Querying Nearby Content ```php // Fetch relations to nearby streams for a location + time window $relations = Places_Nearby::byTime( $publisherId, 'MyPlugin/events', // relation type $fromTimestamp, // unix timestamp $toTimestamp, // unix timestamp 'Streams/experience/main', // fallback if no lat/lon array( 'latitude' => $latitude, 'longitude' => $longitude, 'meters' => $meters ) ); // Returns array of Streams_RelatedTo objects // Fetch relations directly from nearby streams $relations = Places_Nearby::related( $publisherId, 'MyPlugin/events', $latitude, $longitude, $meters ); // Returns Streams_RelatedTo rows sorted by ascending weight. // NOTE: Some results may be slightly out of range — filter with: $dist = Places::distance($lat1, $lon1, $lat2, $lon2); // meters if ($dist <= $maxMeters) { /* in range */ } // Geohash range query (alternative to grid system) $range = Places_Nearby::geohashRange($latitude, $longitude, $meters); // Returns Db_Range for prefix queries: $rows = MyPlugin_Thing::select('*') ->where(array('geohash' => $range)) ->fetchDbRows(); ``` --- ## 3. User Location ```php // Get logged-in user's location stream $stream = Places_Location::userStream(); // null if not logged in $stream = Places_Location::userStream(true); // throws if not logged in // Read location attributes $lat = $stream->getAttribute('latitude'); $lng = $stream->getAttribute('longitude'); $tz = $stream->getAttribute('timezone'); $pc = $stream->getAttribute('postcode'); $m = $stream->getAttribute('meters'); // Update user location programmatically Places_Location::changed(array( 'user' => $user, 'latitude' => 40.7128, 'longitude' => -74.0060, 'meters' => 50000, 'source' => 'geolocation', // or 'ip' 'timezone' => -5, // Flags: 'joinNearby' => 0, // 0=no, 1=join, 2=join+subscribe 'leaveNearby' => 0, 'joinInterests' => 0, 'leaveInterests' => 0 )); // Saves to Places/user/location stream, posts Places/location/updated message // Set user location from any stream with lat/lon attributes Places::setUserLocation($locationStream, $onlyIfNotSet); // Get defaults (from user stream or config fallback) list($latitude, $longitude, $meters) = Places_Nearby::defaults(); ``` User location streams (auto-created): | Stream Name | Purpose | |---|---| | `Places/user/location` | Current location (geolocation or IP) | | `Places/user/location/ip` | Last IP-based location | | `Places/user/location/home` | Home address | | `Places/user/location/work` | Work address | | `Places/user/locations` | Category of saved locations | --- ## 4. Venue Hierarchy (Location → Area → Floor/Column) ```php // Create or fetch a location stream from Google Places placeId $location = Places_Location::stream($asUserId, $publisherId, $placeId, array( 'withTimeZone' => true )); // Attributes: latitude, longitude, viewport, address, phoneNumber, // types, rating, website, placeId, timeZone // Add an area within a location list($area, $floor, $column) = Places_Location::addArea( $location, // parent location stream 'VIP Section', // area title '2', // floor number (optional) 'A' // column name (optional) ); // Creates Places/area/{placeId}/{normalizedName} stream // Relates to location via 'Places/areas' // Optionally creates Places/floor/{placeId}/2 and Places/column/{placeId}/a // Read location from a stream's attributes $loc = Places_Location::fromStream($stream); // Returns array: ['latitude' => ..., 'longitude' => ..., ...] ``` Stream types and their relations: ``` Places/location └── Places/areas → Places/area ├── Places/floor → Places/floor └── Places/column → Places/column └── (can contain Places/table) ``` --- ## 5. Autocomplete & Google Places ```php // Autocomplete (cached, uses Google Places API) $predictions = Places::autocomplete( 'starbucks near times square', false, // throwIfBadValue array('establishment'), // types (or true for all) 40.7580, -73.9855, // search center (null = user location) 40234 // radius in meters ); // Returns array of Google prediction objects // Supported types: establishment, locality, sublocality, postal_code, // country, administrative_area_level_1, administrative_area_level_2 // Get timezone for coordinates $tz = Places::timezone($latitude, $longitude); // Returns Google Timezone API response: timeZoneName, timeZoneId, etc. // Requires: Places.google.keys.server config ``` --- ## 6. Distance & Geometry ```php // Haversine distance between two points (returns meters) $meters = Places::distance($lat1, $lon1, $lat2, $lon2); // Human-readable distance label $label = Places::distanceLabel(5000, 'km'); // "5 km" $label = Places::distanceLabel(8047, 'miles'); // "5 miles" // Heading in degrees from point 1 to point 2 $degrees = Places::heading($lat1, $lon1, $lat2, $lon2); // Geohash encode/decode $hash = Places_Geohash::encode(40.7128, -74.0060, 6); // "dr5ru7" // Length 6 ≈ ±610m precision // Quantize coordinates to grid list($latQ, $lonQ, $latGrid, $lonGrid) = Places::quantize($lat, $lon, $meters); ``` --- ## 7. Geographic Reference Data ```php // Look up a country $country = new Places_Country(); $country->countryCode = 'US'; $country->retrieve(); // ->englishName, ->localName, ->emojiFlag, ->population, ->continent, ->currencyCode // Look up a city by geonameId $city = new Places_City(); $city->geonameId = 5128581; $city->retrieve(); // ->englishName, ->localName, ->countryCode, ->latitude, ->longitude, ->geohash, ->timeZone // Find nearest cities by geohash $cities = Places_City::fetchByDistance($geohash, 10, array( 'countryCode' => 'US' )); // Look up postcode $postcode = new Places_Postcode(); $postcode->countryCode = 'US'; $postcode->postcode = '10001'; $postcode->retrieve(); // ->latitude, ->longitude, ->geonameId, ->geohash // Find nearby postcodes $postcodes = Places_Postcode::nearby($latitude, $longitude, $meters, $limit); // Returns array of Places_Postcode objects sorted by distance // IP geolocation $row = Places::lookupFromRequest(array( 'join' => array('city', 'country', 'postcode'), 'trustProxy' => false )); // Returns Db_Row with geonameId, countryCode, latitude, longitude, postcode // Direct IP lookup $row = Places_Ipv4::lookup($ipv4String, $options); $row = Places_Ipv6::lookup($ipv6String, $options); // Region/district lookups $region = new Places_Region(); $region->countryCode = 'US'; $region->regionCode = 'NY'; $region->retrieve(); // ->englishName = "New York" $district = new Places_District(); $district->countryCode = 'US'; $district->regionCode = 'NY'; $district->districtCode = '061'; $district->retrieve(); // ->englishName = "New York County" ``` --- ## 8. Interest-Based Nearby ```php // Create/fetch category streams for a geographic interest $streams = Places_Interest::streams( $publisherId, $latitude, $longitude, 'Live Music', // interest title (normalized) array('experienceId' => 'main') ); // Creates streams like: Places/interest/main/{geohash}/{meters}/live_music // Query related content by interest + location + time $relations = Places_Interest::related( $publisherId, 'MyPlugin/events', 'Live Music', 'main', // experienceId array( 'latitude' => $lat, 'longitude' => $lon, 'meters' => 50000 ) ); ``` --- ## 9. Polylines & Routes ```php // Decode a Google Directions route to polyline $polyline = Places::polyline($googleRoute, array('platform' => 'google')); // Returns array of ['x' => lat, 'y' => lng] points // Find closest point on a polyline to a given point $result = Places::closest( array('x' => $lat, 'y' => $lng), $polyline ); // Returns: index, x, y, distance, fraction ``` --- ## 10. Configuration Reference ``` Places.nearby.meters — array of distance tiers [1000, 5000, ...] Places.nearby.defaultMeters — default tier (50000) Places.nearby.units — 'km' or 'miles' Places.google.keys.server — Google API key (server-side, REQUIRED) Places.google.keys.web — Google API key (client-side Maps JS) Places.location.default.latitude/longitude — fallback when no user location Places.location.ip.changed — max IP-location updates (false=disable, true=unlimited, N=limit) Places.location.ip.meters — radius for IP-based location Places.location.cache.duration — cache TTL for Google API responses (30 days default) Places.geolocation.requireLogin — whether geolocation POST requires login ``` --- ## 11. Common Mistakes | Wrong | Right | |-------|-------| | Using arbitrary meters value with `forSubscribers` | Must be one of the values in `Places/nearby/meters` config | | `Places_Nearby::subscribe(...)` without checking login | `subscribe`/`join` call `Users::loggedInUser(true)` — throws if not logged in | | Trusting nearby results as exact matches | Filter with `Places::distance()` — grid overlap means some results are slightly out of range | | Setting user location with `$stream->save()` | Use `Places_Location::changed()` — handles postcode lookup, geohash, join/leave, events | | Hardcoding coordinates in nearby stream names | Use `Places_Nearby::streamName()` — handles geohash + meters encoding | | Forgetting `Places.google.keys.server` config | Autocomplete and location lookup throw without it | | Using `Places_Postcode::nearby()` for city lookup | Use `Places_City::fetchByDistance()` — postcodes are for zip/postal codes | | `Places::quantize()` with raw $meters | Use `$meters * 2` for the grid (publishers use double-radius cells) | | Querying `Places_Ipv4` with string IP | Columns are `INT UNSIGNED`; use `INET_ATON()` or the `lookup()` method | --- ## 12. Key Schema ### places_postcode ```sql countryCode varchar(2) KEY postcode varchar(10) KEY (countryCode, postcode) geonameId int NULL latitude double NULL longitude double NULL geohash varchar(31) KEY ``` ### places_city ```sql geonameId int PK AUTO_INCREMENT countryCode varchar(2) KEY normalizedName varchar(180) KEY englishName varchar(180) localName varchar(180) KEY regionGeonameId int NULL districtGeonameId int NULL latitude double longitude double geohash varchar(31) KEY timeZone varchar(40) NULL population int NULL featureCode varchar(10) NULL ``` ### places_country ```sql countryCode varchar(2) PK countryCode3 varchar(3) geonameId int KEY phoneCode varchar(20) normalizedName varchar(180) englishName varchar(180) localName varchar(180) emojiFlag varchar(8) area bigint KEY population bigint KEY continent varchar(2) KEY currencyCode varchar(3) KEY currencyName varchar(64) ``` ### places_region ```sql geonameId int PK countryCode varchar(2) regionCode varchar(20) englishName varchar(180) localName varchar(180) NULL UNIQUE (countryCode, regionCode) ``` ### places_location (geohash index for streams) ```sql geohash varchar(31) KEY publisherId varbinary(31) streamName varbinary(255) insertedTime timestamp ``` ### places_ipv4 ```sql ipMin int unsigned PK ipMax int unsigned PK geonameId int KEY countryCode varchar(2) KEY postcode varchar(20) KEY latitude double NULL longitude double NULL accuracy int NULL ``` ### places_autocomplete (cache) ```sql query varchar(127) PK types varchar(31) PK latitude double PK longitude double PK meters double PK results text updatedTime timestamp NULL ``` # Travel Plugin — LLM Coding Primer Supplement to the Q Framework, Streams, Places, and Calendars primers. Covers trip creation, the participant state machine, real-time coordinate tracking, route calculation, and ridesharing lifecycle. --- ## 1. Creating a Trip ```php // Create a trip (as driver) $result = Travel_Trip::create($asUserId, array( 'attributes' => array( 'type' => 'Travel/from', // 'Travel/from' (depart at time) or 'Travel/to' (arrive by time) 'from' => array('latitude' => 40.7128, 'longitude' => -74.0060), 'to' => array('latitude' => 40.7580, 'longitude' => -73.9855), 'startTime' => time() + 900, // required for Travel/from 'endTime' => null, // required for Travel/to 'venue' => 'Times Square', 'peopleMax' => 4, 'detourMax' => 15, 'detourType'=> 'minutes', // 'minutes', 'kilometers', 'miles' 'labels' => null // tab-delimited access labels ), 'recurringInfo' => array( // null for one-time trips 'period' => 'weekly', 'days' => array('Mon' => array(), 'Wed' => array()) ) ), $relateToStream); // optional event/category to relate to // Returns: ['stream' => Streams_Stream, 'participant' => Streams_Participant] // Driver state is set to 'planning' // Route is calculated via Google Directions // Trip is related to Places/nearby streams at origin and destination // Notifications sent to nearby subscribers ``` --- ## 2. Participant State Machine ```php // Valid transitions: $STATES = array( 'observing' => array('waiting'), // passenger default 'waiting' => array('riding', 'canceled'), 'riding' => array('expelled', 'arrived'), 'planning' => array('driving', 'discontinued'), // driver default 'driving' => array('completed', 'stopped', 'discontinued'), 'stopped' => array('driving', 'discontinued') ); // Set participant state (validates transition, posts message) Travel_Trip::setState($tripStream, $userId, 'waiting', array( 'coordinates' => array('latitude' => 40.72, 'longitude' => -74.00) )); // Throws Travel_Exception_StateTransition if invalid transition // Posts Travel/trip/user/state message // If state='riding': recalculates route, posts pickup message // If state='waiting'|'canceled': updates recurring participation ``` --- ## 3. Trip Lifecycle ```php // DRIVER: Start the trip Travel_Trip::start($tripStream, $driverId, array( 'latitude' => 40.7128, 'longitude' => -74.0060, 'heading' => 45.0 )); // Transitions driver: planning → driving // Sets trip state to 'started', updates origin to current position // Recalculates route with current traffic data // Throws if not the publisher or if state != 'planning' // PASSENGER: Join the trip Travel_Trip::join($tripStream, $passengerId, array( 'latitude' => 40.7200, 'longitude' => -73.9950 )); // Checks peopleMax capacity // Checks detourMax limits (calculates hypothetical route) // Sets passenger coordinates and state to 'waiting' // Subscribes to trip notifications // Recalculates route with new waypoint // Throws Streams_Exception_Full if at capacity // Throws Travel_Exception_TripDuration if detour exceeds limit // Throws Travel_Exception_TripAlreadyStarted if trip already started // Returns false if passenger is the driver or already joined // PASSENGER: Leave the trip Travel_Trip::leave($tripStream, $passengerId); // Transitions: waiting → canceled, riding → expelled // Removes passenger coordinates, recalculates route // Leaves stream (no more notifications) // DRIVER: Complete the trip Travel_Trip::completed($tripStream, $driverId); // Sets all participants to 'arrived', closes stream // Only publisher can call this // DRIVER: Discontinue the trip Travel_Trip::discontinue($tripStream, $driverId); // Sets all participants to 'discontinued', closes stream // Closes recurring category if exists ``` --- ## 4. Real-Time Coordinate Tracking ```php // Update a participant's position Travel_Trip::setCoordinates($tripStream, $userId, array( 'latitude' => 40.7135, 'longitude' => -74.0050, 'heading' => 90.0 // optional )); // Saves to travel_trip.coordinates JSON // When driver updates position: // 1. Checks distance to each waiting passenger // 2. If within 'arriving' distance → posts Travel/trip/arriving // 3. If within 'pickup' distance → auto-sets passenger to 'riding' // 4. Checks distance to destination → posts Travel/trip/finishing // 5. Checks deviation from route polyline // 6. If deviation > 'route' distance → recalculates route // Remove coordinates (on leave) Travel_Trip::setCoordinates($tripStream, $userId, null); // Removes from JSON and recalculates route // Read coordinates $coords = Travel_Trip::getCoordinates($tripStream, $userId); // Returns: ['latitude' => ..., 'longitude' => ..., 'heading' => ...] $allCoords = Travel_Trip::getAllCoordinates($tripStream); // Returns: {userId => {latitude, longitude, heading}, ...} ``` --- ## 5. Route Calculation ```php // Calculate/recalculate route (Google Directions API) $result = Travel_Trip::route($tripStream, $waypoints, array( 'travelMode' => 'driving', // 'driving', 'bicycling', 'transit', 'walking' 'optimize' => true, // optimize waypoint order 'startTime' => $timestamp, // departure time 'endTime' => $timestamp // arrival time (use one or the other) )); // $waypoints: array of ['userId'=>..., 'location'=>[lat,lon], 'stopover'=>true] // If $waypoints empty, auto-collects from all waiting passengers' coordinates // Stores full response in travel_trip.directions // Builds pickups[] array mapping optimized waypoint order to userIds // Updates startTime/endTime based on calculated duration // Returns Google Directions API response with added 'request' and 'pickups' // Requires: Places.google.keys.server config ``` --- ## 6. Querying Trips ```php // Trips the user is participating in $streams = Travel_Trip::participating( $userId, // null = logged-in user strtotime('-1 hour'), // fromTime (earliest endTime) strtotime('+1 day'), // untilTime (latest startTime) array('planning', 'driving', 'waiting'), // state filter (or comma-delimited string) array('asUserId' => $userId) ); // Returns array of Streams_Stream objects // Get trip's related event $event = Travel_Trip::getEventByTrip($tripStream); // Returns Streams_Stream (Calendars/event) or false // Get participant $participant = Travel_Trip::participant($tripStream, $userId); // Returns Streams_Participant (throws if not found) ``` --- ## 7. Nearby Notifications ```php // Sent automatically on trip creation: Travel_Trip::sendNotifications($tripStream); // Finds Places/nearby streams within Travel.Trip.distances.subscribe meters // of the trip's anchor point (origin for Travel/from, destination for Travel/to) // Posts Travel/trip/added message with venue and link info // Subscribers to those Places/nearby streams receive the notification // To subscribe to trips near a location: Places_Nearby::subscribe($communityId, $latitude, $longitude, $meters); ``` --- ## 8. Recurring Trips ```php // Trips can be recurring via Calendars_Recurring integration // Pass recurringInfo in Travel_Trip::create(): $result = Travel_Trip::create($userId, array( 'attributes' => array(/* ... */), 'recurringInfo' => array( 'period' => 'weekly', 'days' => array('Mon' => array(), 'Fri' => array()) ) ), $eventStream); // Creates Calendars/recurring category, relates to event's recurring category // Per-user day preferences stored in recurring participant extras // When passenger joins/cancels, their recurring participation auto-updates // via Travel_Trip::changeUserParticipation() ``` --- ## 9. Common Mistakes | Wrong | Right | |-------|-------| | Setting participant state directly on `Streams_Participant` | Use `Travel_Trip::setState()` — validates transitions, posts messages, handles recurring | | Creating trip with `Streams::create()` | Use `Travel_Trip::create()` — handles routing, Places/nearby relations, notifications, recurring | | Calling `join()` as the driver (publisher) | `join()` is for passengers only; driver uses `start()` | | Letting passengers join a started trip | `join()` throws `Travel_Exception_TripAlreadyStarted`; by design | | Updating coordinates without the stream | Always pass the trip stream to `setCoordinates()` — it handles proximity detection and route recalculation | | Missing `Places.google.keys.server` config | Route calculation requires a Google Directions API key | | Using `Travel/to` without `endTime` | `Travel/to` requires `endTime`; `Travel/from` requires `startTime` | | Modifying `travel_trip.directions` directly | Use `Travel_Trip::route()` — it handles waypoint collection, optimization, and time estimates | --- ## 10. Key Schema ### travel_trip (extends Travel/trip streams) ```sql publisherId varbinary(31) PK streamName varbinary(255) PK directions mediumtext NULL -- full Google Directions API JSON response coordinates text NULL -- JSON: {userId: {latitude, longitude, heading}} ``` **All other data** is in stream attributes and participant extras: - **Stream attributes:** from, to, startTime, endTime, state, type, venue, peopleMax, detourMax, detourType, labels, lastPickup - **Participant extras:** state, startTime, endTime, timestamp, coordinates, gotArrivingNote --- ## 11. Configuration Reference ``` Travel.Trip.arriveTime = 7200 — default arrive window (seconds from now) Travel.Trip.departTime = 900 — default depart window (seconds from now) Travel.Trip.distances.route = 100 — meters deviation before route recalculation Travel.Trip.distances.arriving = 100 — meters from passenger for arriving notification Travel.Trip.distances.pickup = 10 — meters from passenger for auto-pickup Travel.Trip.distances.riding = 100 — meters threshold while riding Travel.Trip.distances.mapZoom = 100 — meters movement before map re-zoom Travel.Trip.distances.subscribe = 1000 — radius for sending trip-added notifications ``` # Assets Plugin — LLM Coding Primer Supplement to the Q Framework and Streams primers. Covers credits, payments, subscriptions, badges, leaderboards, and NFTs. --- ## 1. Credits — The Virtual Currency ```php // Check balance $balance = Assets_Credits::amount($communityId, $userId); // Grant credits (system → user) Assets_Credits::grant($communityId, 100, 'MyPlugin/reward', $userId, array( 'toPublisherId' => $publisherId, 'toStreamName' => $streamName )); // Spend credits (user → system) Assets_Credits::spend($communityId, 50, 'MyPlugin/purchase', $userId, array( 'toPublisherId' => $publisherId, 'toStreamName' => $streamName )); // Throws Assets_Exception_NotEnoughCredits if balance < 50 // Transfer credits between users Assets_Credits::transfer($communityId, 25, 'MyPlugin/tip', $toUserId, $fromUserId, array( 'forcePayment' => false // true = auto-charge if insufficient ) ); // Refund a previous spend Assets_Credits::refund($communityId, 50, 'MyPlugin/refund', $fromUserId, $toUserId ); // Convert currencies $credits = Assets_Credits::convert(2.50, 'USD', 'credits'); // 250 (at 100:1) $usd = Assets_Credits::convert(250, 'credits', 'USD'); // 2.50 // Format for display $label = Assets_Credits::format(250); // "250 credits" or localized // Get the credits stream (per community per user) $stream = Assets_Credits::stream($communityId, $userId); // Stream name: "Assets/credits/{communityId}" // Attribute 'amount' = current balance, 'peak' = highest ever ``` **NEVER write to `assets_credits` directly — always use grant/spend/transfer/refund.** These methods maintain the journal, update the stream balance, and fire events. --- ## 2. Unified Payment — Assets::pay() ```php // Pay for something — the main entry point $result = Assets::pay( $communityId, $userId, 5.00, // amount 'MyPlugin/premium', // reason — REQUIRED, appears in ledger array( 'currency' => 'USD', // default 'USD' 'payments' => 'stripe', // 'stripe', 'authnet', 'web3' 'toPublisherId' => $publisherId, 'toStreamName' => $streamName, 'toUserId' => $recipientId, // for marketplace payments 'autoCharge' => false, // true = charge saved card 'items' => array( // line items array('amount' => 3.00, 'description' => 'Item A'), array('amount' => 2.00, 'description' => 'Item B') ) ) ); // NEVER throws — check result: if ($result['success']) { // credits were deducted } else { $needCredits = Q::ifset($result, 'details', 'needCredits', 0); $intentToken = Q::ifset($result, 'details', 'intentToken', null); // Client should prompt user to buy credits Q_Response::setSlot('result', array( 'success' => false, 'needCredits' => $needCredits, 'intentToken' => $intentToken )); } ``` --- ## 3. Real-Money Charges ```php // Direct charge (bypasses credit system — use pay() instead when possible) Assets::charge('stripe', 10.00, 'USD', array( 'userId' => $userId, 'description' => 'Premium upgrade', 'metadata' => array('streamName' => $streamName) )); // After charge succeeds (webhook or synchronous) Assets::charged('stripe', 10.00, 'USD', array( 'userId' => $userId, 'communityId' => $communityId )); // Converts to credits and calls Assets_Credits::grant() // Auto-charge saved payment method Assets::autoCharge(20000, 'BoughtCredits', array( 'userId' => $userId, 'communityId' => $communityId )); // Requires prior Stripe checkout with setup_future_usage = 'off_session' // charge.status: 'pending' → 'completed' | 'failed' | 'reversed' ``` --- ## 4. Subscriptions ```php // Start subscription to a plan $subscription = Assets_Subscription::start($planStream, $user, array( 'payments' => 'stripe', 'amount' => 9.99, 'currency' => 'USD' )); // Creates Assets/subscription stream related to the plan // Adds inheritAccess so subscriber can access gated content // Check if user is subscribed $isSubscribed = Assets_Subscription::isSubscribed($planStream, $user); // Check if subscription is current (not expired) $isCurrent = Assets_Subscription::isCurrent($subscriptionStream); // Check if a stream requires payment (via related plan) $paid = Assets_Subscription::checkStreamPaid($stream, $user, false); // $throwIfNotPaid = false returns boolean; true throws // Get plans related to a stream $plans = Assets_Subscription::getPlansRelated($stream); // Unsubscribe Assets_Subscription::unsubscribe($subscriptionStream); // Plan stream attributes typically include: // amount, currency, interval (monthly/yearly), trialDays, features[] ``` --- ## 5. Badges & Leaderboards ```php // Award a badge Assets_Earned::badge($userId, 'first_post', array( 'publisherId' => $stream->publisherId, 'streamName' => $stream->name, 'duplicate' => false // false = prevent awarding twice )); // Badge definition (in DB or via code) $badge = new Assets_Badge(); $badge->appId = Q::app(); $badge->communityId = $communityId; $badge->name = 'first_post'; $badge->title = 'First Post'; $badge->description = 'Made your first post'; $badge->points = 10; $badge->save(true); // Leaderboard — get badges and totals for a date range $results = Assets_Badge::badgesAndTotals( strtotime('-30 days'), // fromTime time(), // untilTime array('communityId' => $communityId) ); // Returns: ['userId' => ['badges' => [...], 'total' => 37], ...] ``` --- ## 6. Products & Services ```php // Create a product stream $product = Streams::create($asUserId, $publisherId, 'Assets/product', array( 'title' => 'Premium Widget', 'content' => 'Description of the widget', 'attributes' => Q::json_encode(array( 'price' => 9.99, 'currency' => 'USD' )) )); // Create a service stream $service = Streams::create($asUserId, $publisherId, 'Assets/service', array( 'title' => 'Consulting Hour', 'attributes' => Q::json_encode(array( 'price' => 150, 'currency' => 'USD', 'duration' => 3600 )) )); ``` --- ## 7. Credit Grants for User Actions Config in `Assets.credits.grant`: ```json { "Users/insertUser": 0, "forStreams": { "Streams/user/firstName": 5, "Streams/user/lastName": 5, "Streams/user/icon": 5, "Places/user/location": 5 }, "Users/newUserAcceptedYourInvite": 10, "invitedUserEntered": { "Streams/user/firstName": 1, "Streams/user/lastName": 1 } } ``` Credits are awarded automatically when users fill profile fields, accept invites, or complete onboarding steps. The `Assets/after/Streams_save` hook checks these configs on every stream save. --- ## 8. NFTs ```php // Create an NFT stream $nft = Streams::create($asUserId, $publisherId, 'Assets/NFT', array( 'title' => 'My NFT #1', 'attributes' => Q::json_encode(array( 'chainId' => '0x89', 'contractAddress' => $contractAddress, 'tokenId' => $tokenId, 'price' => '0.1', 'currency' => 'ETH' )) )); // NFT contract stream $contract = Streams::create($asUserId, $communityId, 'Assets/NFT/contract', array( 'title' => 'My Collection', 'attributes' => Q::json_encode(array( 'chainId' => '0x89', 'contractAddress' => $address, 'symbol' => 'MYC', 'standard' => 'ERC721' )) )); // NFT series $series = Streams::create($asUserId, $publisherId, 'Assets/NFT/series', array( 'title' => 'Genesis Series', 'attributes' => Q::json_encode(array( 'maxSupply' => 1000, 'price' => '0.05', 'currency' => 'ETH' )) )); // User streams (auto-created): // Assets/user/NFTs — category of user's NFTs // Assets/NFT/contracts — user's custom contracts // Assets/NFT/series — user's series // On-chain ABIs at: views/Assets/templates/R1/NFT/contract.abi.json // Factory ABIs at: views/Assets/templates/R1/NFT/factory.abi.json ``` --- ## 9. Currency & Formatting ```php // App's default currency $currency = Assets::appCurrency(); // Format amount in a currency $formatted = Assets::format('USD', 9.99, false); // "$9.99" $formatted = Assets::format('USD', 9.99, true); // "$9.99 USD" // Currency info $info = Assets::currency('USD'); // Returns: name, symbol, decimals, ... // ERC-20 token addresses (from config) // Assets.currencies.tokens.USDC.0x89 = "0x2791..." ``` --- ## 10. JS Client-Side ```javascript // Pay for something Assets.pay({ communityId: communityId, amount: 5.00, currency: 'USD', reason: 'MyPlugin/premium', toPublisherId: publisherId, toStreamName: streamName, onSuccess: function (result) { ... }, onFailure: function (result) { if (result.needCredits) { // Show buy-credits dialog Assets.Credits.buy({ amount: result.needCredits, onSuccess: function () { // retry payment } }); } } }); // Get credits stream Assets.Credits.getStream(function (stream) { var balance = stream.getAttribute('amount'); }); ``` --- ## 11. Common Mistakes | Wrong | Right | |-------|-------| | Writing to `assets_credits` table directly | Use `Assets_Credits::grant/spend/transfer/refund` — maintains journal + stream | | `Assets::pay()` and checking for exceptions | `pay()` never throws — check `$result['success']` | | Calculating credit totals manually | Balance is `Assets_Credits::amount()` or stream attribute `amount` | | Stripe charge without `setup_future_usage` | Auto-charge only works if first checkout used `'off_session'` | | Using `Assets_Credits::spend()` for user-to-user | Use `Assets_Credits::transfer()` — creates both debit and credit entries | | Setting `amount` attribute on credits stream directly | Use credit operations — they handle stream updates via hooks | | `Assets_Earned::badge()` without `'duplicate' => false` | Omitting this allows the same badge to be awarded multiple times | | Hardcoding exchange rate | Use `Assets_Credits::convert()` — reads `Assets.credits.exchange` config | --- ## 12. Key Schema ### assets_credits ```sql id varbinary(31) PK fromUserId varbinary(31) KEY -- null for system grants toUserId varbinary(31) KEY -- null for system debits fromPublisherId varbinary(31) NULL fromStreamName varbinary(255) NULL toPublisherId varbinary(31) NULL toStreamName varbinary(255) NULL reason varchar(255) -- Q.Text key communityId varbinary(31) NOT NULL amount decimal(10,4) NOT NULL -- positive=credit, negative=debit attributes varchar(1023) NULL -- JSON insertedTime timestamp updatedTime timestamp NULL ``` ### assets_charge ```sql userId varbinary(31) PK id varbinary(255) PK publisherId varbinary(31) DEFAULT '' streamName varbinary(255) DEFAULT '' amount decimal(10,2) NULL currency char(3) NULL credits bigint NULL paymentProvider varchar(32) NULL providerCustomerId varchar(255) NULL autoCharge tinyint(1) DEFAULT 0 communityId varbinary(31) NULL app varchar(64) NULL reason varchar(64) NULL description varchar(255) attributes varchar(1023) status enum('pending','completed','failed','reversed') DEFAULT 'pending' insertedTime timestamp updatedTime timestamp NULL ``` ### assets_customer ```sql userId varbinary(31) PK payments enum('stripe','authnet') PK hash varchar(32) PK -- disambiguates test/live keys customerId varbinary(255) -- e.g. cus_XXXX insertedTime timestamp updatedTime timestamp ``` ### assets_badge ```sql appId varbinary(31) PK communityId varbinary(31) NULL name varchar(63) PK icon varbinary(255) NULL title varchar(255) description text NULL points smallint DEFAULT 0 ``` ### assets_earned ```sql appId varbinary(31) -- no single PK (multiple earns OK) communityId varbinary(31) NULL earnedTime timestamp userId varchar(31) badgeName varchar(255) publisherId varbinary(31) NULL streamName varbinary(255) NULL KEY byTime (appId, communityId, earnedTime) KEY byUser (appId, communityId, userId) ``` ### assets_leader ```sql communityId varbinary(31) PK day date PK userId varchar(31) PK points smallint DEFAULT 0 ``` --- ## 13. Configuration Reference ``` Assets.credits.exchange.USD = 100 — credits per dollar Assets.credits.amount.min = 1000 — auto-charge when balance ≤ this Assets.credits.amount.add = 20000 — how many credits to auto-charge Assets.credits.bonus.bought = {50000:5000,...} — bonus tiers Assets.credits.grant.* — credits for user actions Assets.credits.spend.* — credit costs per operation Assets.credits.buyLink — URL for buying credits Assets.payments.stripe.jsLibrary — Stripe.js URL Assets.payments.reasons.* — payment reason configs Assets.charges.simulate.failed — simulate failed charges (testing) Assets.currencies.tokens.* — ERC-20 addresses per chain Assets.NFT.URI.base — NFT metadata URI template ``` # AI Plugin — LLM Coding Primer Supplement to Q Framework and Streams primers. Covers LLM execution, observations, image generation, voice sessions, transcription, NER, and the intelligence pipeline. --- ## 1. LLM Execution ```php // Route to an LLM adapter by deployment config $llm = AI_LLM::route('smart'); // resolves via AI/llm/routes → providers $llm = AI_LLM::route('fast'); // e.g. gpt-4o-mini $llm = AI_LLM::route('search'); // smart + webSearch=true auto-set // Direct adapter creation $llm = AI_LLM::create('Anthropic', array('model' => 'claude-sonnet-4-6')); $llm = AI_LLM::create('Openai'); $llm = AI_LLM::create('Google'); // Execute a model call (ONE RPC, no retries/batching) $response = $llm->executeModel( 'You are a helpful assistant.', // instructions (system prompt) array( // inputs (multimodal artifacts) 'text' => 'Describe this image', 'images' => array($binaryImageData), // raw binary, not URLs 'pdfs' => array($binaryPdfData) // Claude 3.5+ documents ), array( // options 'model' => 'claude-sonnet-4-6', // override default 'temperature' => 0.7, 'max_tokens' => 2000, 'messages' => array( // conversation timeline array('role' => 'user', 'content' => 'Previous question'), array('role' => 'assistant', 'content' => 'Previous answer'), array('role' => 'user', 'content' => 'Follow-up') ), 'response_format' => 'json_schema', // structured output 'json_schema' => $schema, // JSON Schema object 'schema_name' => 'my_output', 'callback' => function($result) { /* async handler */ } ) ); // Returns string (sync) or integer request index (async/batch) // Web search (adapter must supportsWebSearch()) $result = $llm->searchAndRespond( 'Answer using current sources.', 'Latest AI regulation news', array('webSearch' => true, 'includeCitations' => true) ); // Returns string or {text, citations[]} // List configured routes and providers $routes = AI_LLM::listRoutes(); // ['smart', 'fast', 'search', ...] $providers = AI_LLM::listProviders(); // ['anthropic-direct', 'openai-direct', ...] ``` **JS (Node.js) — identical pattern:** ```js var AI_LLM = require('AI/LLM'); var llm = AI_LLM.route('smart'); var result = await llm.executeModel(instructions, inputs, options); ``` --- ## 2. Observations ```php // Process multimodal inputs through config-driven observations $observations = AI_LLM::observations('Streams/image', 'holiday'); // Loads from observations.json: {semanticExtraction, holidayAnalysis, ...} $results = $llm->process( array('images' => array($imageData)), // inputs $observations, // observation definitions array('currentYear' => date('Y')), // interpolation vars array('structured' => true) // use native structured outputs ); // Returns: {semanticExtraction: {title, holidayName, startDate, ...}, ...} // Create a stream from observation results (with policy gate) $stream = AI_LLM::createStream( 'Streams/image', // stream type 'holiday', // observation type array('icon' => $iconUrl), // stream creation data $results, // LLM observation results array( 'publisherId' => $communityId, 'accept' => true // or callable; default rejects obscene/controversial ) ); // Returns Streams_Stream or false if policy gate rejects // Extract flat attributes from observation results $attributes = AI_LLM::attributesFromObservationResults( $results, 'Streams/image', 'holiday' ); // Returns: {title, holidayName, startDate, countries, ...} // Build prompt + schema from observation definitions $o = AI_LLM::promptFromObservations($observations); // Returns: {clauses: [...], schema: {...}} // Build strict JSON Schema from observations $schema = AI_LLM::jsonSchemaFromObservations($observations); // Returns: full JSON Schema with types, required, additionalProperties:false ``` **Observation definition format (observations.json):** ```json { "Streams/image": { "holiday": { "semanticExtraction": { "promptClause": "Extract visible text, identify holiday...", "fieldNames": ["title", "holidayName", "startDate", "endDate"], "example": { "title": "Happy Valentine's Day", "startDate": "2026-02-14" } }, "contentClassification": { "promptClause": "Classify content type, occasion, tone...", "fieldNames": ["contentType", "occasion", "tone", "sentiment"], "example": { "contentType": "greeting card", "tone": "cheerful" } } } } } ``` --- ## 3. Named Entity Recognition (No LLM Cost) ```php // Lightweight regex NER — runs before LLM calls $entities = AI_LLM::extractEntities($transcriptText); // Returns: { // persons: ['Tim Cook', 'Elon Musk'], // orgs: ['NVIDIA', 'OpenAI', 'Google AI'], // topics: ['artificial intelligence', 'chip shortage'], // numbers: [{value: '100', unit: 'billion', raw: '100 billion'}, ...], // hashtags: ['AI', 'tech'] // } // Build search queries from entities (no LLM cost) $queries = AI_LLM::buildSearchQueries($entities, 'AI conference'); // Returns: ['Tim Cook AI conference', 'Tim Cook', 'NVIDIA AI conference', ...] // Match entities against Qbix avatars (database lookup, no LLM) $avatars = AI_LLM::lookupStreams( array('Tim Cook', 'Elon Musk'), array('limit' => 3, 'public' => true) ); // Returns: {publisherId => Streams_Avatar, ...} ``` --- ## 4. Summarization & Keywords ```php // Summarize text $result = $llm->summarize($longText); // Returns: {title, keywords: [...], summary, speakers} // Expand keywords for search indexing $expanded = $llm->keywords( array('machine learning', 'neural network'), 'insert', // 'insert' (broad) or 'query' (narrow) array('language' => 'es'), // multilingual expansion $keywordsNative // filled with Spanish keywords ); // Returns: ['machine learning', 'deep learning', 'ML', 'neural net', ...] // $keywordsNative: ['aprendizaje automático', 'red neuronal', ...] ``` --- ## 5. Image Generation ```php // Create image adapter via config $img = AI_Image::create('openai'); // or 'aws', 'google', 'ideogram' // Generate image $result = $img->generate('A serene mountain landscape', array( 'model' => 'dall-e-3', 'width' => 1024, 'height' => 1024, 'format' => 'png', 'images' => array($referenceImage) // optional reference for style transfer )); // Returns: {data: binary, format: 'png'} or {b64_json: '...'} // Remove background $result = $img->removeBackground($imageData, array( 'background' => 'transparent', 'format' => 'png' )); ``` --- ## 6. Realtime Voice Sessions ```php // Broker a realtime voice session $broker = AI_Voice::route('conversational'); $session = $broker->createSession(array( 'voice' => 'alloy', 'instructions' => 'You are a helpful assistant.', 'turn_detection' => array('type' => 'server_vad'), 'tools' => array(/* function definitions */), 'audioFormat' => 'pcm16', 'sampleRate' => 24000 )); // Returns: { // token: 'eph_...', wsUrl: 'wss://...', protocol: 'openai-realtime', // model: 'gpt-4o-realtime-preview', expiresAt: 1234567890, // mode: 'direct' // browser connects to provider directly // } // Return $session to browser → AI.Voice.connect(session) // Protocols: 'openai-realtime', 'gemini-live', 'pipecat', 'livekit' // Mode 'proxy': server relays audio (Gemini Live, no client-safe tokens) ``` --- ## 7. Transcription ```php // Async transcription (webhook-based) $transcriber = AI_Transcription::create('assemblyai'); $result = $transcriber->transcribe($audioUrl, array( '_diarization' => array('max' => 5) // speaker detection )); // Returns: transcript ID for later fetch // Fetch completed transcript $transcript = $transcriber->fetch($transcriptId); // Realtime streaming: AI.speech.provider = 'deepgram' // Client sends mic audio via Qbix socket → DeepgramStream on Node.js // Config: AI.transcription.deepgram.key, endpointing, interimResults ``` --- ## 8. Discourse Integration ```php // AI-powered forum reply with attitude selection // POST to AI/discourse with: // userId, apiKey, topicUrl, attitude, language // Available attitudes: // 'agree + actionable' — agreement + insights // 'agree + emotive' — enthusiastic agreement // 'agree + expand' — agree then broaden scope // 'agree + changeSubject' — agree then pivot to bigger issue // 'disagree + respectful' — polite counterarguments // 'disagree + emotive' — opinionated with aphorisms // 'disagree + absurd' — sarcastic analogies // 'disagree + authority' — cite respected authorities ``` --- ## 9. Stream Commands ```php // Registered in Streams.commands.AI config: // AI/image/generate — routing: 'background' — generates images // AI/tool/generate — routing: 'veto' — generates tools // AI/slide/generate — routing: 'veto' — AI-composed HTML slides // Commands are dispatched via Streams command system // 'veto' routing means proposals can be vetoed before execution // 'background' routing means execution happens asynchronously ``` --- ## 10. Common Mistakes | Wrong | Right | |-------|-------| | Calling provider APIs directly | Use `AI_LLM::route('smart')` — provider-agnostic, deployment-configurable | | Passing image URLs in inputs | Pass raw binary data in `inputs.images`; adapters handle encoding | | Adding retries/batching in adapter code | Adapters do ONE RPC only; orchestration belongs in higher layers | | Hardcoding model names | Set model in `AI/llm/providers/{name}/config`; override via options | | Using `process()` without observations | `process()` requires observation definitions; use `executeModel()` for freeform | | Skipping the policy gate in `createStream()` | Default `accept()` rejects obscene > 3, controversial > 5, confidence < 0.6 | | Running NER via LLM | Use `extractEntities()` first (regex, free); LLM disambiguates the candidates | | Storing voice tokens server-side | Voice tokens are ephemeral — send to browser, browser connects directly | | Forgetting `offline.access` scope for voice | Realtime voice uses ephemeral tokens, not OAuth — no scope needed | | Using `makeStrict()` for Gemini schemas | Use `geminiSchema()` — Gemini rejects `additionalProperties` | --- ## 11. Configuration Reference ``` AI.llm.default — fallback provider name AI.llm.providers.{name}.class — adapter class (Anthropic, Openai, Google, Aws, VertexAi, Local) AI.llm.providers.{name}.config — model, apiKey overrides AI.llm.routes.{name} — route → provider mapping (smart, fast, search, vision) AI.llm.structuredOutputs — enable native structured outputs globally AI.anthropic.apiKey — Anthropic direct API key AI.anthropic.baseUrl — defaults to https://api.anthropic.com AI.openAI.key — OpenAI API key AI.google.key — Google AI Studio key AI.aws.region — AWS Bedrock region AI.image.default — default image provider AI.image.providers.{name} — model and options per provider AI.voice.provider — voice provider (openai, elevenlabs, google, null) AI.voice.key — voice provider API key AI.speech.provider — client STT (deepgram, browser, null) AI.speech.sampleRate — mic sample rate (default 16000) AI.transcription.provider — server STT (assemblyai, deepgram, null) AI.transcription.deepgram.key — Deepgram API key AI.transcription.assemblyai.key — AssemblyAI API key AI.observations.{plugin} — observations JSON file path per plugin ``` # Calendars Plugin — LLM Coding Primer Supplement to the Q Framework, Streams, Places, and Assets primers. Covers events, going states, recurring schedules, availabilities, reminders, and calendar export. --- ## 1. Creating Events ```php // Full event creation with location, payment, recurring $event = Calendars_Event::create(array( 'eventTitle' => 'Weekly Meetup', 'interestTitle' => 'Hiking', // or array of titles 'placeId' => $googlePlaceId, // Google Places ID 'localStartDateTime' => '2024-03-15 18:00', // local to venue timezone 'localEndDateTime' => '2024-03-15 20:00', // optional 'duration' => '2:00', // alternative to endTime (H:M) 'startTime' => null, // unix timestamp overrides localStartDateTime 'endTime' => null, // unix timestamp overrides localEndDateTime 'publisherId' => $communityId, // defaults to logged-in user 'communityId' => $communityId, // for relating to community calendar 'experienceId' => 'main', // or array of IDs 'description' => 'Meet at the trailhead', 'icon' => $imageUrlOrData, // auto-searched if omitted 'teleconference' => false, // true = create WebRTC room 'peopleMin' => 2, 'peopleMax' => 20, 'labels' => "Users/members\tUsers/guests", // tab-delimited access labels 'payment' => array( // null = free event 'type' => 'required', // 'required' or 'optional' 'amount' => 10, 'currency' => 'USD' ), 'recurring' => array( // null = one-time event 'period' => 'weekly', 'days' => array('Mon' => array(), 'Wed' => array()) ), 'contact' => 'organizer@example.com' ), $skipAccess); // Returns Streams_Stream (the event) // Auto-relates to: community calendar, location, interests, nearby grid // Event stream attributes after creation: // startTime, endTime, timezoneName, venue, communityId, peopleMin, peopleMax, // labels, labelTitles, payment, teleconference, eventUrl, ticketsUrl, userId ``` --- ## 2. Going States (RSVP) ```php // Set a user's going state $participant = Calendars_Event::going($eventStream, $userId, 'yes', array( 'skipPayment' => false, // true = bypass payment check 'paid' => false, // set if already paid externally 'autoCharge' => false, // true = auto-charge saved card 'skipRecurringParticipant'=> false, // true = don't update recurring prefs 'skipSubscription' => false, // true = join without subscribing 'relatedParticipants' => null // array of related streams (pets, etc.) )); // Throws Q_Exception if event already started // Throws Streams_Exception_Full if peopleMax reached // Returns Streams_Participant with extra.going = 'yes'|'maybe'|'no' // Check going state $going = Calendars_Event::getGoing($eventStream, $userId); // Returns 'yes', 'no', or 'maybe' // With participant object $result = Calendars_Event::getGoing($eventStream, $userId, true); // Returns ['participant' => Streams_Participant|null, 'going' => 'yes'|'no'|'maybe'] // Payment flow when going='yes' on a paid event: // 1. Assets::pay() is called // 2. If credits sufficient → going='yes', role='registered' // 3. If credits insufficient → going='maybe', role='requested', // paymentIntent returned for client-side payment prompt // 4. After payment webhook → going upgraded to 'yes' via // Calendars/after/Assets_credits_spend hook ``` **Participant roles (grouped — granting one revokes siblings):** ``` Group 1: rejected, requested, registered Group 2: attendee, arrived ``` --- ## 3. Querying Events ```php // Events the user is participating in $streams = Calendars_Event::participating( $userId, // null = logged-in user strtotime('-1 hour'), // fromTime (earliest endTime) strtotime('+30 days'), // untilTime (latest startTime) array('yes', 'maybe') // going filter — string or array ); // Returns array of Streams_Stream objects // Also available on the Calendars class (same signature) $streams = Calendars::participating($userId, $fromTime, $untilTime, $going); // Get community calendar stream $calendar = Calendars::stream($communityId, 'main'); // Returns Calendars/calendar/main stream (creates if missing) // Get event info for iCal export $info = Calendars_Event::info($eventStream, 'America/New_York'); // Returns: publisherId, streamName, startTime, endTime, start, end, // timezoneName, title, content, url, address, createdTime // Check admin permissions $isAdmin = Calendars_Event::isAdmin($userId, $communityId); // Checks roles: Calendars/admins, Users/owners, Users/admins ``` --- ## 4. Recurring Events ```php // Make an event recurring $recurringCategory = Calendars_Recurring::makeRecurring($eventStream, array( 'period' => 'weekly', 'days' => array( 'Mon' => array(), // all day 'Wed' => array(array('9:00', '10:00')), // specific time slots 'Fri' => array() ) )); // Creates Calendars/recurring stream with period + days attributes // Relates event to recurring category via 'Calendars/recurring' type // Get recurring category from an event $recurringStream = Calendars_Recurring::fromStream($eventStream); // Returns Streams_Stream or null // Get last event in a recurring series $lastEvent = Calendars_Recurring::getLastStream($recurringStream); // Calculate next occurrence time $nextTime = Calendars_Recurring::calculateTime($currentStartTime, array( 'period' => 'weekly', 'days' => array('Mon' => array(), 'Thu' => array()) )); // Returns unix timestamp of next valid day // Set per-user recurring preferences $participant = Calendars_Recurring::setRecurringParticipant($eventStream, array( 'period' => 'weekly', 'days' => array('Mon' => array(array('9:00','10:00'))), 'startDate' => '2024-03-01', 'endDate' => '2024-06-01', 'relatedParticipants' => array( array('publisherId' => $pid, 'streamName' => $sn) ), 'updateExistingStreams' => true, // apply to future events 'skipStream' => false // skip current event )); // Stored in Streams_Participant.extra on the Calendars/recurring stream ``` --- ## 5. Availabilities (Service Booking) ```php // Create or update an availability $availability = Calendars_Availability::aggregate(array( 'template' => array( // REQUIRED: Assets/service stream 'publisherId' => $publisherId, 'streamName' => 'Assets/service/Q123' ), 'location' => array( // REQUIRED (or teleconference) 'placeId' => $googlePlaceId, 'area' => array('publisherId' => $pid, 'streamName' => $areaName) ), 'timeSlots' => array( // REQUIRED 'Mon' => array(array('9:00', '10:00'), array('14:00', '15:00')), 'Wed' => array(array('10:00', '11:00')), 'Fri' => array(array('9:00', '10:00')) ), 'teleconference' => false, 'recurringStartDate' => '2024-03-01', 'recurringEndDate' => '2024-06-01', 'peopleMin' => 1, 'peopleMax' => 5, 'labels' => array('Users/members' => 'Members') )); // Returns Calendars/availability stream // Auto-creates recurring category and relates to Assets/service + location // Generate events from an availability $events = Calendars_Availability::createEvents($availabilityStream, array( 'recurring' => true, // participate user during creation 'paymentCheck' => false, // true = just check cost, don't create 'userId' => $userId )); // Returns array of Calendars/event streams (or cost check results) // Chain: Assets/service → Calendars/availability → Calendars/event // The service template provides: title, content, icon, price, currency, payment type // The availability provides: timeSlots, location, capacity, recurring rules // Events are the bookable instances created from the availability ``` --- ## 6. Payment ```php // Set payment info on an event Calendars_Payment::setInfo($eventStream, array( 'type' => 'required', // 'required', 'optional', or 'free' 'amount' => 25, 'currency' => 'USD' )); // Validates against Calendars.events.defaults.payment.amountMin/amountMax // Saves to stream attribute 'payment' // Payment is enforced in Calendars_Event::going() when type='required' // Publishers and admins bypass payment automatically ``` --- ## 7. CSV Import ```php // Import events from CSV via task stream Calendars_Event::import($taskStream); // $taskStream->instructions = JSON array of CSV rows // Required columns: event_title, interest, venue_address, start_time // Optional: end_time, venue_name, venue_area, event_image_url, contact, // event_description, tickets_url, speaker, leader // Multiple start_time values (newline-separated) create multiple events // Duplicate detection: same title + location + startTime = update, not create // Posts Streams/task/progress messages during processing ``` --- ## 8. Reminders & Calendar Export ```php // Reminder config (seconds before event): // 86400 (24hr), 18000 (5hr), 7200 (2hr), 3600 (1hr), 600 (5min) // Cron: scripts/Calendars/reminders.php posts Calendars/reminder messages // ICS export routes: // {publisherId}/{eventId}/add.ics → single event ICS file // {publisherId}/{eventId}/add.gcal → Google Calendar link // Calendars/personal/{userId}.ics → full personal calendar (capability-protected) // Generate RRULE for recurring event $rrule = Calendars_Event::recurrenceRule($publisherId, $streamName, $userId); // Returns: "RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR" or null // Reschedule an event (shifts affected events in the calendar) $event = Calendars_Event::reschedule($publisherId, $streamName, $newStartTime, array( 'asUserId' => $userId )); // Updates startTime/endTime on the event AND shifts other events // in the same calendar that fall between old and new times ``` --- ## 9. WebRTC & Livestream ```php // Events with teleconference=true get a Media/webrtc room auto-created // Related via 'Calendars/event/webrtc' // Post notification to event about teleconference state Calendars_Event::postMessage($webrtcStream, 'join'); // first join → started Calendars_Event::postMessage($webrtcStream, 'leave'); // last leave → ended // Message types: Calendars/event/webrtc/started, Calendars/event/webrtc/ended // Calendars/event/livestream/started, Calendars/event/livestream/ended ``` --- ## 10. Utility Methods ```php // Default event duration from config $seconds = Calendars_Event::defaultDuration(); // default 7200 (2 hours) // Calculate start time from local datetime + location $timestamp = Calendars_Event::calculateStartTime('2024-03-15 18:00', $locationStream); // Uses location's timeZone attribute for conversion // Calculate duration $seconds = Calendars_Event::calculateDuration('2024-03-15 18:00', '2024-03-15 20:00'); // Get event interests (related Streams/interest streams) $interests = Calendars_Event::getInterests($eventStream); // Returns array of {publisherId, name, title} // Get event location (from related Places/location + Places/area) $location = Calendars_Event::getLocation($eventStream); // Returns: publisherId, name, venue, address, latitude, longitude, timeZone, area // Get availability from event $availability = Calendars_Event::getAvailability($eventStream); // Get service from event (via availability) $service = Calendars_Event::getService($eventStream); // Get nearest date from time slots $dates = Calendars_Event::getNearestDate($timeSlots, $locationStream); // Returns [startTimestamp, endTimestamp] for next matching slot // Relate event to community calendar Calendars_Event::relateToCommunity($eventStream, $communityId); ``` --- ## 11. Common Mistakes | Wrong | Right | |-------|-------| | Creating events with `Streams::create()` directly | Use `Calendars_Event::create()` — handles location, interests, calendar relations, recurring, payment, icon import | | Setting going state by modifying participant extra | Use `Calendars_Event::going()` — handles payment, capacity, roles, recurring, subscriptions | | Using `startTime` in local timezone | `startTime`/`endTime` are always UTC unix timestamps; use `localStartDateTime` + `placeId` for timezone conversion | | Forgetting `timezoneName` when no `placeId` | If no location, must pass `timezoneName` (e.g. "America/New_York") | | Creating recurring events manually | Use `Calendars_Recurring::makeRecurring()` — creates category stream, sets up participant preferences | | Checking payment status on participant directly | Use `Assets_Credits::getPaymentsInfo($userId, $stream)` — the canonical payment check | | Accessing `calendars_event` table directly for location | Use `Calendars_Event::getLocation()` or `Places_Location::fromStream()` — the table is a denormalized cache | | Creating availabilities without Assets/service template | Availability requires a `template` param pointing to an `Assets/service` stream | | Assuming `going('yes')` always sets going to 'yes' | If payment required and insufficient credits, going is set to 'maybe' with a paymentIntent | --- ## 12. Key Schema ### calendars_event (extends Calendars/event streams) ```sql publisherId varbinary(31) PK streamName varbinary(255) PK interests varchar(2046) NULL -- JSON: [{publisherId, name, title}, ...] location varchar(1023) NULL -- JSON: {publisherId, name, venue, address, lat, lon, timeZone, area} ``` **No other custom tables.** All other data is stored in: - **Stream attributes:** startTime, endTime, timezoneName, venue, payment, peopleMin/Max, timeSlots, location - **Participant extras:** going (yes/no/maybe), startTime, period, days, startDate, endDate, relatedParticipants, role - **Relations:** events→calendar (weight=startTime), events→recurring, events→availability, events→location, events→interest --- ## 13. Configuration Reference ``` Calendars.events.defaults.duration = 7200 — default event duration (seconds) Calendars.events.defaults.peopleMin = 0 — default min attendees Calendars.events.defaults.peopleMax = 100 — default max attendees Calendars.events.defaults.payment.* — amountMin, amountMax, currency Calendars.events.admins — roles that can manage events Calendars.event.reminders — reminder intervals {seconds: {selected: bool}} Calendars.event.templateStyle — 'classic', 'tall', or 'square' Calendars.event.hideLocationIfNotPaid — hide venue until user pays Calendars.event.icon.search — image search services for event icons Calendars.newEvent.location — show location picker in composer Calendars.newEvent.teleconference — show teleconference toggle Calendars.user.calendars.alerts.minutes — personal calendar alert lead time ``` # Media Plugin — LLM Coding Primer Supplement to Q Framework, Streams, Users, Websites, and Calendars primers. Covers WebRTC rooms, livestreaming, presentations, webcasting, clips/feeds/channels, and live shows. --- ## 1. WebRTC Rooms ```php // Create or fetch a WebRTC room $stream = Media_WebRTC::getOrCreateRoomStream( $publisherId, // room owner $roomId, // null = auto-generate, or explicit ID $resumeClosed, // true = reopen existing closed stream array( // access levels 'readLevel' => 40, // max 'writeLevel' => 23, // relate 'adminLevel' => 20 // invite ), array('mic', 'camera', 'screen') // permissions added to stream ); // Auto-creates Streams_Access rows for Users/hosts and Users/screeners // Stream name: Media/webrtc/{roomId} // Schedule a meeting with invited attendees $result = Media_WebRTC::scheduleOrUpdateRoomStream( $streamName, // null = create new $publisherId, // null = create new array( 'topic' => 'Weekly Standup', 'startTimeTs' => $startTimestamp * 1000, // milliseconds 'endTimeTs' => $endTimestamp * 1000, 'timeZoneString' => 'America/New_York', 'accessType' => 'trusted', // 'trusted' or 'open' 'scheduleLivestream' => true, // auto-create livestream 'invitedAttendeesIds'=> array($userId1, $userId2) // or 'none' ) ); // Returns: ['webrtcStream' => ..., 'livestreamStream' => ...] // Fetch an existing room $stream = Media_WebRTC::fetchStream($publisherId, $roomId, $resumeClosed); // Waiting room flow (trusted access rooms) $waitingRoom = Media_WebRTC::createWaitingRoomStream(); // Sets attribute isWaitingRoom=true // Host admits user from waiting room Media_WebRTC::admitUserToRoom($publisherId, $streamName, $waitingRoomStreamName, $userIdToAdmit); // Creates per-user Streams_Access row with read=max, write=relate, admin=invite // Posts Media/webrtc/admit message to waiting room // Host puts participant back in waiting room Media_WebRTC::cancelAccessToRoom($publisherId, $streamName, $userId); // Sets readLevel=none on user's access row // Sends putInWaitingRoom command to Node.js signaling // Host dismisses waiting room Media_WebRTC::closeWaitingRoom($publisherId, $streamName, $waitingRoomStreamName, $waitingRoomUserId); // TURN server credentials $iceServers = Media_WebRTC::getTwilioTurnCredentials(); // Returns Twilio ICE server array for WebRTC NAT traversal ``` --- ## 2. Livestreaming ```php // Create or update a livestream (related to a WebRTC room) $livestreamStream = Media_Livestream::createOrUpdateLivestreamStream( $publisherId, // WebRTC room publisher $streamName, // WebRTC room stream name (Media/webrtc/xxx) $startTimestamp // unix seconds (null = use scheduled time or now) ); // Creates Media/webrtc/livestream stream // Relates to WebRTC room via 'Media/webrtc/livestream' type // Get existing livestream for a room $livestreamStream = Media_Livestream::getLivestreamStream($publisherId, $streamName); // Manage RTMP destinations (YouTube Live, Facebook, custom) $destStream = Media_Livestream::createOrUpdateChannel( $livestreamPublisherId, $livestreamStreamName, json_encode(array( 'destId' => 'youtube_main', 'platform' => 'youtube', 'rtmpUrl' => 'rtmp://...', 'streamKey'=> '...' )), false // true = remove destination ); // Creates Media/livestream/dest/{destId} stream, relates to livestream // Audience tracking (race-condition-safe) Media_Livestream::joinAudience($publisherId, $streamName, $socketId); // Atomically increments $.audience in stream attributes via JSON_SET SQL // Registers Node.js disconnect handler to auto-decrement Media_Livestream::leaveAudience($publisherId, $streamName); // Atomically decrements $.audience (min 0) // Reminders $reminders = Media_Livestream::updateReminders($publisherId, $streamName, '3600', 'set'); // Stores in participant extra.reminders = {'3600': false, ...} // Sets subscription rule for 'livestream' delivery ``` --- ## 3. Presentations ```php // Create or fetch presentation + commands chat for a WebRTC room $result = Media_WebRTC::getOrCreatePresentation($webrtcStreamName, $publisherId); // Returns: // 'presentationStream' — Media/presentation stream // 'commandsChatStream' — Streams/chat for slide navigation commands // 'publisherId', 'streamName', 'calendarId', 'isHost', // 'toolPublisherId', 'toolStreamName', 'writeLevel' // Presentation stream includes attributes: // mode='broadcast', backgroundGallery with Ken Burns settings // Commands sent as chat messages on the tool stream: // Media/presentation/show — navigate to slide // Media/presentation/start — start presentation // Media/presentation/end — end presentation // Media/presentation/graph/update — update a chart // Media/presentation/table/update — update a table // Media/slide/build — build slide animation // Fetch or create presentation for an experience Media::presentationStream($asUserId, $publisherId, $experienceId); // Creates Media/presentation/{experienceId}, relates to Streams/experience/{experienceId} ``` --- ## 4. Webcasting ```php // Simpler than WebRTC — one-way broadcasting $stream = Media_Webcast::getOrCreateStream($publisherId, $roomId, array( 'readLevel' => 40, 'writeLevel' => 23, 'adminLevel' => 20 )); // Stream name: Media/webcast/{roomId} $stream = Media_Webcast::fetchStream($publisherId, $roomId, $resumeClosed); $stream = Media_Webcast::createStream($publisherId, $roomId, $accessLevels); ``` --- ## 5. Clips, Channels & Feeds ```php // Create a video clip and relate to a feed Media::createClip(array( 'video' => '/path/to/video.mp4', 'publisherId' => $publisherId, 'feedId' => 'main', // last part of Media/feed/{feedId} 'image' => '/path/to/thumb.jpg', // optional thumbnail 'duration' => 120000 // milliseconds )); // Creates Media/clip stream // Attaches video via Websites_File::saveStreamFile() // Sets icon from thumbnail // Relates to Media/feed/{feedId} with weight=time() // Get feeds for the current user (or all community feeds for admins) $relations = Media::filterFeeds(array( 'communityId' => $communityId, 'limit' => 100, 'offset' => 0 )); // Check feed creation permission $authorized = Media::newFeedAuthorized($userId, $communityId); // Checks Media.access.feeds.admins roles or Media.feeds.anyoneNewFeed config // Check admin status $isAdmin = Media::isFeedsAdmin($userId, $communityId); ``` --- ## 6. Live Shows ```php // Get or create today's live show (one per day per community) $liveStream = Media::getCurrentLiveShow(); // Fetches most recent Media/live stream // If created today → returns it // Otherwise → creates new one with WebRTC room, Users/hosts access // Get the WebRTC room for the current live show $webrtcStream = Media::getCurrentLiveShowWebrtc(); // Live shows auto-create: // 1. Media/live stream with video/audio config attributes // 2. Media/webrtc room related via configured relation type // 3. Streams_Access for Users/hosts with full permissions // 4. mic/camera/screen permissions on the WebRTC stream ``` --- ## 7. Recordings ```php // Merge parallel recordings from a WebRTC call Media_WebRTC::mergeRecordings($publisherId, $roomId); // Scans {files_dir}/Media/webrtc_rec/{roomId}/{startTime}/ // Each participant has a JSON metadata file with timing info // Uses ffmpeg to mix audio tracks with proper time offsets // Output: audio.mp3 in the call directory // User recordings category Media::userRecordingsStream($userId); // Creates/fetches Media/user/recordings stream ``` --- ## 8. Integration with Calendars ```php // WebRTC rooms relate to Calendars/event via 'Calendars/event/webrtc' // Livestreams relate to events via 'Calendars/event/livestream' // See Calendars_Event::create() with teleconference=true // When a WebRTC room participant joins/leaves, Calendars_Event::postMessage() // posts notification messages to the related event stream: // Calendars/event/webrtc/started — first participant joins // Calendars/event/webrtc/ended — last participant leaves // Calendars/event/livestream/started — livestream begins // Calendars/event/livestream/ended — livestream ends ``` --- ## 9. Common Mistakes | Wrong | Right | |-------|-------| | Creating WebRTC streams with `Streams::create()` | Use `Media_WebRTC::getOrCreateRoomStream()` — handles permissions, access rows, resumeClosed | | Manually tracking audience count with read-increment-write | Use `Media_Livestream::joinAudience()` — race-condition-safe JSON_SET SQL | | Creating presentations without a commands chat stream | Use `Media_WebRTC::getOrCreatePresentation()` — creates both and relates them | | Forgetting `resumeClosed` when reopening a room | Pass `$resumeClosed = true` to reopen existing streams; otherwise a new stream is created | | Setting access on WebRTC streams without host/screener rows | `getOrCreateRoomStream()` auto-creates access for `Users/hosts` and `Users/screeners` | | Creating clips without attaching the video file | Use `Media::createClip()` — handles `Websites_File::saveStreamFile()`, icon, and feed relation | | Admitting users to open rooms | `admitUserToRoom()` is only for `"trusted"` access rooms; open rooms have public access levels | | Scheduling meetings without `accessType` | Must specify `'trusted'` or `'open'`; determines whether waiting rooms are used | | Missing Twilio config for TURN | `Media.twilio.accountSid/apiKey/apiSecret/authToken` required for `getTwilioTurnCredentials()` | --- ## 10. Stream Types Quick Reference ``` Media/webrtc — Video conferencing room (permissions: mic, camera, screen) Media/webrtc/livestream — Broadcast layer on a WebRTC room Media/webrtc/waitingRoom — Waiting room for trusted-access rooms Media/livestream/dest — RTMP output destination (YouTube, Facebook, etc.) Media/webcast — One-way broadcast (simpler than WebRTC) Media/presentation — Slides with real-time navigation and Ken Burns backgrounds Media/clip — Video clip with file attachment and thumbnail Media/episode — Episode grouping clips Media/channel — Content directory for clips Media/feed — Social content feed Media/live — Daily live show (one per day per community) Media/calls — Call center category Media/whiteboard — Collaborative drawing User streams: Media/calls/main — Community call center (onInsert: community) Media/user/recordings — Personal recordings Media/channels/main — Channels category Media/channel/main — Default channel ``` --- ## 11. Configuration Reference ``` Media.twilio.accountSid — Twilio account for TURN Media.twilio.apiKey — Twilio API key Media.twilio.apiSecret — Twilio API secret Media.twilio.authToken — Twilio auth token Media.access.feeds.admins — Labels that can manage feeds Media.feeds.anyoneNewFeed — Allow any user to create feeds Media.liveShow.video — Default video config for live shows Media.liveShow.audio — Default audio config for live shows Media.clip.webrtc.relations.main — Relation type for live show WebRTC rooms Users.quotas.Media/webrtc.86400 — Daily room creation limits by role ``` # Websites Plugin — LLM Coding Primer Supplement to the Q Framework and Streams primers. Covers URL scraping, webpage streams, articles, permalinks, theme extraction, and news aggregation. --- ## 1. Scraping URLs into Streams ```php // Scrape a URL and create a Websites/webpage stream (or Streams/video, Streams/audio) $stream = Websites_Webpage::createStream(array( 'url' => 'https://example.com/page', 'asUserId' => $userId, // null = logged-in user 'publisherId' => $publisherId // null = logged-in user ), 'Websites/webpage/chat', false); // Returns existing stream if URL was already scraped // Quota name: 'Websites/webpage/chat' or 'Websites/webpage/conversation' // Third arg: $skipAccess // Stream attributes after creation: // url, urlParsed, host, port, copyright, contentType, lang, // interest: {publisherId, streamName} // Stream icon is imported from the page's og:image or apple-touch-icon // Just scrape without creating a stream $data = Websites_Webpage::scrape('https://example.com'); // Returns: title, url, description, keywords, iconBig, iconSmall, // image, host, port, headers, lang, type, ... // Check if stream already exists for a URL $stream = Websites_Webpage::fetchStream('https://example.com/page'); // Returns Streams_Stream or null ``` **URL → stream type detection:** ```php $type = Websites_Webpage::getStreamType($url); // 'Streams/video' — youtube.com, vimeo.com, .mp4, .avi, etc. // 'Streams/audio' — soundcloud.com, .mp3, .wav, etc. // 'Websites/webpage' — everything else ``` **Stream name convention:** ```php $name = $streamType . '/' . Websites_Webpage::normalizeUrl($url); // e.g. "Websites/webpage/https___example_com_page" // normalizeUrl: Q_Utils::normalize(), truncated to 200 chars ``` --- ## 2. Scrape Caching ```php // Check cache (30-day TTL by default) $cached = Websites_Webpage::cacheGet($url); // Returns array or false // Write to cache Websites_Webpage::cacheSet($url, $resultArray, $durationSeconds); // $duration defaults to 2592000 (30 days) // Cache is stored in websites_webpage table: // PK: url (varchar 191) // results: JSON text // duration: int (TTL in seconds) ``` --- ## 3. YouTube Integration ```php // Get info about a single video $videos = Websites_Webpage::youtube(array( 'videoId' => 'dQw4w9WgXcQ' )); // Returns array of: platform, videoId, title, icon, iconBig, // description, keywords, publishTime, url // Search videos $results = Websites_Webpage::youtube(array( 'query' => 'qbix framework tutorial', 'channel' => $channelId, // optional 'maxResults' => 10, 'order' => 'date' // date, rating, relevance, viewCount )); // Get raw API response $raw = Websites_Webpage::youtube(array( 'videoId' => 'dQw4w9WgXcQ', 'pureResult' => true )); // Requires config: Websites.youtube.keys.server ``` --- ## 4. Articles ```php // Articles use stream type "Websites/article" with extended table // The Websites_Article class extends the stream via the 'extend' mechanism // Create an article stream $article = Streams::create($asUserId, $publisherId, 'Websites/article', array( 'title' => 'My Article', 'content' => 'Summary/teaser' )); // The full HTML body goes in the article table: $wa = new Websites_Article(); $wa->publisherId = $article->publisherId; $wa->streamName = $article->name; $wa->userId = $asUserId; $wa->article = '...